ConventionMapper 1.10.0

dotnet add package ConventionMapper --version 1.10.0
                    
NuGet\Install-Package ConventionMapper -Version 1.10.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ConventionMapper" Version="1.10.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ConventionMapper" Version="1.10.0" />
                    
Directory.Packages.props
<PackageReference Include="ConventionMapper" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ConventionMapper --version 1.10.0
                    
#r "nuget: ConventionMapper, 1.10.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ConventionMapper@1.10.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ConventionMapper&version=1.10.0
                    
Install as a Cake Addin
#tool nuget:?package=ConventionMapper&version=1.10.0
                    
Install as a Cake Tool

ConventionMapper

NuGet Downloads License: MIT

A fast, convention-based object-object mapper for .NET. It removes the boring code that copies one object into another (entities → DTOs, view models, API contracts) with conventions, profiles and compiled mapping code.

Targets .NET Standard 2.0 (works on .NET Framework 4.6.1+, .NET Core, Unity, Xamarin) and .NET 8+.

dotnet add package ConventionMapper

Why ConventionMapper

  • Convention first. Matching names, flattening, Get methods, constructors and records map with no configuration. Profiles, CreateMap, ForMember, ReverseMap, ProjectTo, AssertConfigurationIsValid and DI registration cover everything else.
  • Data-leak guard. Mark fields such as password hashes or tokens [Sensitive], and any mapping that would copy them into a DTO fails at build time and at startup. See Data-leak guard.
  • Know what changed. mapper.MapWithChanges(dto, entity) updates an object and returns every changed value with its old and new value, ready for audit logs. See Change tracking.
  • Build-time code generation. The ConventionMapper.SourceGenerator package writes plain C# mapping code into your project at compile time: no reflection, Native AOT and trimming safe, as fast as hand-written code, and unmapped members are build errors. See Build-time mapping.
  • Mistakes caught while typing. A bundled analyzer flags unmapped members in CreateMap<,>() in the IDE, with one-click fixes. See Analyzers.
  • Compiled for speed. Every map is compiled into a single typed delegate with direct, non-boxing assignments, and simple maps skip per-call overhead entirely through a static generic cache. See Performance.
  • More built in. Collection merging by key, PATCH mapping, strict enum validation, immutable collections, full-path error messages and Explain() ship in the core package, with no add-ons. See Exclusive built-in features.
  • MIT licensed. Free for every use, commercial included, with no license key.

Performance

How it works

  1. One compiled delegate per map. The first time a map is used, it is compiled into a strongly typed expression tree. Person → PersonDto becomes the equivalent of new PersonDto { Id = src.Id, Name = src.Name, ... }.
  2. Direct assignments, no boxing. Numbers, dates, enums, strings and flattened paths such as Customer.Address.City are copied directly, with null-safe navigation. Conditions, pre-conditions and null substitutes compile to plain if checks. Only members that genuinely need more (resolvers, custom conversions) call into the engine, member by member, so results are identical to the interpreter.
  3. Nested maps and collections inlined. On the fast path, child maps are expanded in place and collections are filled by typed loops (for over arrays and List<T>, the type's own struct enumerator otherwise), with no per-element delegate calls and no boxed enumerators. Existing destination objects and collections are reused exactly as by the interpreter.
  4. Zero-overhead fast path. Maps made only of direct assignments and compiled child maps run without a per-call context object. Map<TSource, TDestination>(src) calls a strongly typed Func<TSource, TDestination> held in a static generic field: no dictionary lookup, no casts, no boxing and no try/catch inside the compiled code. Maps that do need a context are called from the same cache, skipping the engine's type-map lookup. If a fast-path call fails, it is re-run on the full path, so the exception still names the exact member.
  5. Pooled context. Maps that need a ResolutionContext (resolvers, conditions, per-call items) reuse one per thread instead of allocating one per call. Call cfg.DisableContextPooling() if a resolver keeps a reference to the context after Map returns.
  6. Automatic fallback. Maps that need reference tracking (cycles, PreserveReferences, MaxDepth), struct destinations and Patch() calls run on the interpreter, with the same behaviour.
var config = new MapperConfiguration(cfg => cfg.AddMaps(typeof(Program).Assembly));
config.CompileMappings();          // optional: compile everything at startup instead of on first use

cfg.UseCompiledMappings = false;   // optional: always use the interpreter

Benchmarks

The repository includes a BenchmarkDotNet project that compares ConventionMapper (runtime and source-generated) with hand-written code and Mapster 7.4:

  • Flat: Person → PersonDto, 8 properties (int, string, DateTime, bool, decimal).
  • Nested: Order → OrderDto with flattening (CustomerAddressCity) and a list of 5 lines.
  • Context: Person → PersonDto with a Condition that reads the ResolutionContext.
dotnet run -c Release --project benchmarks/ConventionMapper.Benchmarks -- --filter *

Results from the repository's benchmark workflow (BenchmarkDotNet 0.13.12, .NET 8.0, AMD EPYC 9V45 GitHub Actions runner; raw reports in benchmarks/results). Lower is better.

Scenario Hand-written ConventionMapper ConventionMapper (generated) Mapster 7.4
Flat: 8 properties 7.8 ns 13.9 ns 7.9 ns 16.0 ns
Nested: flattening + list of 5 79.6 ns¹ 61.9 ns 52.7 ns 69.4 ns

¹ The hand-written nested version uses Select(...).ToList(); the mappers use pre-sized lists and indexed loops.

A map with a Condition (which needs a ResolutionContext) runs in 36.1 ns. Allocations equal hand-written code for flat maps (80 B) and Mapster for nested maps (384 B). Context pooling cuts the per-call allocation of context-dependent maps from 192 B to 80 B. Numbers vary by machine; run the suite yourself with the command above.

Tips for the fastest mapping

  • Prefer mapper.Map<TSource, TDestination>(src) in hot paths: it hits the static cache with a single type check.
  • Call config.CompileMappings() at startup to move compilation cost out of the first request.
  • Keep hot maps simple: convention matches and MapFrom(s => s.A.B) paths compile to direct assignments, while delegates, resolvers and conditions go through the engine.
  • For database queries, use ProjectTo<TDto>() so only the needed columns are selected.

Build-time mapping (source generator)

dotnet add package ConventionMapper.SourceGenerator
[GeneratedMapper]
public static partial class OrderMapper
{
    [MapMember("Customer.Email", "ContactEmail")]
    [MapIgnore("InternalNotes")]
    public static partial OrderDto ToDto(this Order order);

    public static partial void Update(OrderDto source, Order target);

    // EF Core: translated to SQL, only the needed columns are selected
    public static partial IQueryable<OrderDto> ProjectToDto(this IQueryable<Order> query);
}

OrderDto dto = order.ToDto();
List<OrderDto> shipped = await db.Orders.Where(o => o.Shipped).ProjectToDto().ToListAsync();

The generator implements the partial methods at compile time with the same conventions as the runtime mapper (names, flattening, Get methods, constructors and records, nested objects, collections, dictionaries, enums, nullable and numeric conversions). Static methods marked [MapConversion], such as private static string Format(Money m), are used for every member of those types. Bindings that would be a guess (a property and a Get method, two flattenings) are build errors, and parsing or narrowing conversions are warnings until allowed with [MapAllowParse]. Overloads share configuration with [MapInherit(nameof(ToDto))]. Extra method parameters supply values from the caller (ToGap(this Referral r, int submissionNumber)), [MapExplicit("Id", "Name")] copies only the members you list so new columns are never copied silently, [MapNullSubstitute("Status", "")] replaces null sources, and [MapConstant], [MapFrom], [MapRequired], [MapTruncate] and [MapMergeKey] cover constants, computed members, required values, column lengths and merging collections by key. The generator reads the EF Core model from source, so maps that write a database-generated key or a navigation into an entity are flagged. It also generates PATCH updates ([MapPatch]), merges of several sources ([MapCombine]), conditional members ([MapWhen]), database row readers (Read(IDataRecord)), deep clones, equality comparers, validators, log-safe Redact / ToLogString methods and member-by-member Diffs; handles Protobuf messages and immutable and frozen collections; keeps conversions culture-invariant ([MapFormat]); flags [Sensitive] data passed to loggers and exception messages; and offers IDE fixes that convert hand-written copies and implicit operators into generated mappings. Projection methods (IQueryable<TDest> M(IQueryable<TSource>) or Expression<Func<TSource, TDest>> M()) produce expressions that EF Core translates to SQL, tested against a real database in CI. The result is ordinary C# you can step through, with no runtime dependency, so it works with Native AOT and trimming. An unmapped destination member is a build error (CM0001). The build also writes a data-flow report with snapshot-test helpers (DataFlowReport.AssertMatchesDirectory("data-flow")), can keep the generated code in the repository for code review (<ConventionMapperEmitGeneratedCode>Generated</ConventionMapperEmitGeneratedCode>), and records which members each method writes, which the ConventionMapper.Testing package checks against the real behavior (MapperAssert.WritesExactly, WritesFrom, AllMappersMatchWriteSets, RoundTrips, AllMethodsCovered, GeneratedCodeMatches, and SampleData.Builder for test objects). The Native AOT sample is published with PublishAot=true and run on every CI build. See docs/source-generator.md.

Analyzers

The ConventionMapper package ships a Roslyn analyzer with code fixes:

  • CM1001: CreateMap<TSource, TDestination>(); has a destination member with no matching source member. Shown in the IDE while typing instead of when AssertConfigurationIsValid() runs. The fix adds .ForMember(d => d.X, o => o.Ignore()).

  • CM1002: mapper.Map<TDestination>(source) where the source type is known. The fix rewrites it to the faster Map<TSource, TDestination>(source).

  • CM1003 (error): CreateMap<,>() would copy a [Sensitive] member into a member that isn't. The fix ignores the member.

  • Generate build-time mapper (refactoring): on any CreateMap<TSource, TDestination>() call, adds a file with the equivalent [GeneratedMapper] class, translating Ignore, MapFrom member paths and ReverseMap, so moving a map to the source generator takes one click.

Make CM1001 a build error with dotnet_diagnostic.CM1001.severity = error in .editorconfig. See docs/analyzers.md. Every diagnostic code links from the IDE to its section in docs/diagnostics.md.

Data-leak guard ([Sensitive])

Mark members that must never reach an API response, a log or a client:

public class User
{
    public string Email { get; set; }
    [Sensitive] public string PasswordHash { get; set; }
    [Sensitive] public string ApiToken { get; set; }
}

public class UserDto
{
    public string Email { get; set; }
    public string PasswordHash { get; set; }   // matches by name...
}

cfg.CreateMap<User, UserDto>();
// ...so the configuration fails:
// User -> UserDto: 'PasswordHash' would receive the [Sensitive] member 'PasswordHash'.

The guard covers every way a value is copied as is: matching names, flattening (Profile.ApiKey → ProfileApiKey), MapFrom(s => s.PasswordHash), string paths, constructor parameters and ForPath. Values computed from a sensitive member, such as MapFrom(s => s.PasswordHash != null), are allowed.

It is checked in three places:

  • While typing: analyzer CM1003 marks the CreateMap<,>() call as an error, with a fix that ignores the member.
  • At build time: the source generator reports CM0009 and does not generate the copy.
  • At startup: new MapperConfiguration(...) throws MapperConfigurationException, for maps the analyzer cannot see.

To allow a copy on purpose (for example into a cache entry that is protected too), mark the destination member [Sensitive] as well. Sensitive data can only flow into sensitive members.

Data-flow report

For code reviews and privacy audits, get every flow of data through the configuration as Markdown:

File.WriteAllText("data-flow.md", config.DataFlowReport());

Example output:

Sensitive members

Member Copied to
User.PasswordHash not copied anywhere
User.ApiToken CacheEntry.ApiToken (protected)

Order → OrderDto

Destination Source Notes
CustomerName Customer.Name flattened
Total GetTotal()
InternalNotes — ignored

The source generator writes the same report for generated mappers at build time, in ConventionMapper.Generated.DataFlowReport.Markdown; a unit test can save it, or compare it with a checked-in copy so any new flow of personal data shows up in the pull request.

Change tracking (MapWithChanges)

Update an existing object and get back exactly what changed:

MappingChanges changes = mapper.MapWithChanges(dto, entity);

if (changes.HasChanges)
{
    auditLog.Write(changes.ToString());
    // Email: "a@x.com" -> "b@x.com"; Address.City: "London" -> "Paris"; Lines[1].Quantity: 1 -> 5; Lines.Count: 2 -> 3
    await db.SaveChangesAsync();
}

foreach (var change in changes)
    Console.WriteLine($"{change.Path}: {change.OldValue} -> {change.NewValue}");
  • Paths cover nested objects (Address.City), collection elements (Lines[1].Quantity) and sizes (Lines.Count). Collections of simple values are compared as a whole (Codes: [1, 2] -> [1, 2, 3]).
  • changes.Find("Email") returns one change, and changes.Changed("Address") checks a member and everything below it.
  • Members marked [Sensitive] are reported as changed, but their values are never exposed: ApiToken: (sensitive value changed).
  • Per-call options work as usual, so mapper.MapWithChanges(patch, entity, opt => opt.IgnoreNullSourceValues()) gives a PATCH that also reports what it changed.

Built-in features

These ship in the core package, with no add-on packages or hand-written workarounds:

Feature How
Data-leak guard [Sensitive] members can't be copied into unprotected destinations
Change tracking mapper.MapWithChanges(source, destination) returns old and new values
Build-time generated mappers (no reflection, Native AOT) ConventionMapper.SourceGenerator package
Unmapped members reported while typing Bundled analyzer (CM1001) with a code fix
Update collections by key (EF Core) EqualityComparison(...)
Strict, validated enum mapping ConvertUsingEnumMapping(...)
PATCH mapping (skip null source values) mapper.Patch(dto, entity)
Readable description of a map Explain() returns plain text
Precise error location Full path with collection indexes, e.g. OrderDto.Lines[3].Product
Immutable collection destinations ImmutableArray<T>, ImmutableList<T>, ImmutableDictionary<K,V>, ... with no extra dependency
License MIT, free for any use

Update collections by key (EF Core friendly)

cfg.CreateMap<TagDto, TagEntity>().EqualityComparison((dto, entity) => dto.Id == entity.Id);

mapper.Map(postDto, trackedPostEntity);
// matching tags are updated in place, new ones added, missing ones removed;
// the collection instance itself is kept, so EF Core change tracking works

PATCH mapping (ignore null source values)

mapper.Patch(patchDto, entity);                       // per call
cfg.CreateMap<UserPatch, User>().IgnoreNullSourceValues(); // per map
cfg.IgnoreNullSourceValues = true;                     // per profile / globally
mapper.Map(dto, entity, opt => opt.IgnoreNullSourceValues());

Only members whose source value is non-null are written, so a DTO with nullable properties can update just the fields a client sent.

Strict, validated enum mapping

cfg.CreateMap<Color, ColorDto>().ConvertUsingEnumMapping(o => o
    .MapByName()                          // or MapByValue(), MapByName(ignoreCase: true)
    .MapValue(Color.Purple, ColorDto.Violet)
    .Fallback(ColorDto.Unknown));         // optional

Without a Fallback, any source value that has no match is reported by AssertConfigurationIsValid(), and mapping it at runtime throws instead of silently producing a wrong value. Enum maps also work inside ProjectTo.

ReverseMap() creates the map back, with the same matching and every MapValue override inverted:

cfg.CreateMap<Color, ColorDto>().ConvertUsingEnumMapping(o => o
    .MapValue(Color.Purple, ColorDto.Violet)   // ColorDto.Violet -> Color.Purple on the way back
    .Fallback(ColorDto.Unknown)
    .ReverseMap(fallback: Color.Red));         // the reverse map's own fallback (optional)

A forward Fallback can't be inverted (many values map onto it), so the reverse map takes its own. Two overrides onto the same value (Purple → Violet and Magenta → Violet) can't be inverted either: AssertConfigurationIsValid() reports it, and mapping Violet back throws rather than guessing. Calling ReverseMap() on the map itself, before or after ConvertUsingEnumMapping, inverts the mapping too; a reverse map with its own ConvertUsingEnumMapping uses that instead.

Immutable collections

ImmutableArray<T>, ImmutableList<T>, ImmutableHashSet<T>, ImmutableSortedSet<T>, ImmutableQueue<T>, ImmutableStack<T>, ImmutableDictionary<K,V>, ImmutableSortedDictionary<K,V> and their IImmutable* interfaces are supported as destinations, with no extra dependency.

Errors that point at the exact member

Error mapping types.

Mapping types:
String -> Int32

Path:
ParentDto.Items[1].Count

MappingException.MemberPath holds the same path for logging.

Explain a map

Console.WriteLine(config.Explain<Order, OrderDto>());
Order -> OrderDto   (profile 'OrderProfile')
  Construction: parameterless constructor
  Members:
    Id                   <- Id
    CustomerName         <- Customer.Name
    CustomerAddressCity  <- Customer.Address.City
    Lines                <- Lines   [List<OrderLine> -> OrderLineDto[], items via map OrderLine -> OrderLineDto]
    Status               <- Status   [OrderStatus -> OrderStatusDto]
    Total                <- GetTotal()
    Notes                <- Notes   [null substitute: (none)]

Nullability checks

cfg.ValidateNullability = true;
config.AssertConfigurationIsValid();
// LooseSource -> StrictDest, member 'Score': source 'Score' may be null ('Score' is nullable)
// but the destination is not nullable. Add NullSubstitute(...), a Condition, Ignore(), or make the destination nullable.

With ValidateNullability on, validation also reports members whose source can be null while the destination cannot, before they silently become 0, default or null at runtime. Nullable value types (int? → int) are checked on every framework; nullable reference annotations (string? → string) are checked on .NET 6 and later.

Getting started

using ConventionMapper;

var configuration = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<Order, OrderDto>();
    cfg.CreateMap<OrderLine, OrderLineDto>();
});

// Only during development / in a unit test: fail fast on unmapped members
configuration.AssertConfigurationIsValid();

var mapper = configuration.CreateMapper();
OrderDto dto = mapper.Map<OrderDto>(order);

With dependency injection

services.AddConventionMapper(cfg =>
{
    cfg.CreateMap<Foo, FooDto>();
}, typeof(Program)); // scans the assembly containing Program

This registers MapperConfiguration / IConfigurationProvider (singleton) and IMapper (transient). Every Profile and every [MappedFrom] class in the scanned assemblies is added, and these implementations are registered as transient services so they can take constructor dependencies:

IValueResolver<,,>, IMemberValueResolver<,,,>, ITypeConverter<,>, IValueConverter<,>, ICondition<,,>, IPreCondition<,>, IMappingAction<,>.

AddConventionMapper can be called more than once; configuration accumulates.

Profiles

public class OrderProfile : Profile
{
    public OrderProfile()
    {
        CreateMap<Order, OrderDto>()
            .ForMember(d => d.Notes, o => o.NullSubstitute("(none)"))
            .ReverseMap();
    }
}

var config = new MapperConfiguration(cfg => cfg.AddMaps(typeof(OrderProfile).Assembly));

Conventions

Feature Example
Same name (case-insensitive) Name → Name
Flattening Customer.Address.City → CustomerAddressCity
Get methods GetTotal() → Total
Constructor / record mapping record PersonDto(string FirstName, int Age)
Unflattening via ReverseMap() CustomerName → Customer.Name
Collections arrays, List<T>, IEnumerable<T>, ICollection<T>, HashSet<T>, ReadOnlyCollection<T>, ObservableCollection<T>
Dictionaries Dictionary<K,V>, IDictionary<K,V>, IReadOnlyDictionary<K,V> (keys and values mapped)
Enums enum ↔ enum (by name, then value), string ↔ enum, number ↔ enum
Primitive conversions IConvertible, static Parse, implicit/explicit operators, TypeConverter
Nullable int ↔ int?
Null collections become empty collections (set AllowNullCollections = true to keep null)

Naming conventions, prefixes and postfixes:

cfg.SourceMemberNamingConvention = LowerUnderscoreNamingConvention.Instance; // first_name → FirstName
cfg.RecognizePrefixes("Str");
cfg.RecognizeDestinationPostfixes("Dto");
cfg.ReplaceMemberName("Ä", "A");
cfg.AddGlobalIgnore("Internal");

Member configuration

cfg.CreateMap<Person, PersonDto>()
    .ForMember(d => d.FullName,  o => o.MapFrom(s => s.FirstName + " " + s.LastName))
    .ForMember(d => d.Secret,    o => o.Ignore())
    .ForMember(d => d.Age,       o => o.Condition(s => s.Age >= 0))
    .ForMember(d => d.Nickname,  o => o.NullSubstitute("n/a"))
    .ForMember(d => d.Code,      o => o.MapFrom("Details.Code"))            // string path
    .ForMember(d => d.Total,     o => o.MapFrom<TotalResolver>())           // IValueResolver
    .ForMember(d => d.Name,      o => o.ConvertUsing(new UpperConverter(), s => s.LastName)) // IValueConverter
    .ForMember(d => d.Tag,       o => o.MapFrom((s, d, m, ctx) => ctx.Items["Tag"]))
    .ForPath(d => d.Address.City, o => o.MapFrom(s => s.City))
    .ForCtorParam("id",          o => o.MapFrom(s => s.Key))
    .ForSourceMember(s => s.Password, o => o.DoNotValidate())
    .BeforeMap((s, d) => { /* ... */ })
    .AfterMap<AuditAction>();                                                // IMappingAction

Other options: PreCondition, UseDestinationValue, SetMappingOrder, AddTransform, ForAllMembers, IgnoreAllPropertiesWithAnInaccessibleSetter, ConstructUsing, MaxDepth, PreserveReferences, IncludeMembers, DisableCtorValidation.

Validate against the source side instead of the destination side with CreateMap<A, B>(MemberList.Source), or turn validation off for a map with MemberList.None.

Type converters

cfg.CreateMap<string, DateTime>().ConvertUsing(s => DateTime.Parse(s));
cfg.CreateMap<DateTime, string>().ConvertUsing<DateTimeToStringConverter>();
cfg.CreateMap(typeof(Source<>), typeof(Dest<>)).ConvertUsing(typeof(MyGenericConverter<,>));

Inheritance

cfg.CreateMap<Shape, ShapeDto>()
   .Include<Circle, CircleDto>()
   .Include<Square, SquareDto>();
cfg.CreateMap<Circle, CircleDto>();
cfg.CreateMap<Square, SquareDto>();

List<ShapeDto> dtos = mapper.Map<List<ShapeDto>>(shapes); // CircleDto / SquareDto instances

IncludeBase<TBaseSource, TBaseDest>() works the other way round. Member configuration and before/after actions are inherited from base maps.

Open generics

cfg.CreateMap(typeof(Page<>), typeof(PageDto<>));
var dto = mapper.Map<PageDto<UserDto>>(page);

Circular references

Maps that can reach themselves (trees, parent/child links) automatically preserve references, so cycles do not cause a stack overflow and shared instances stay shared. Use MaxDepth(n) to cut a graph at a fixed depth instead.

Attributes

[MappedFrom(typeof(Person), ReverseMap = true)]
public class PersonDto
{
    public string FirstName { get; set; }
    [SourceMember("LastName")] public string Surname { get; set; }
    [IgnoreMap] public string Secret { get; set; }
}

Attribute maps are picked up by AddMaps(...) and by AddConventionMapper(...) assembly scanning.

Mapping into an existing object

mapper.Map(source, existingDestination);   // updates in place; nested objects and collections are reused

Per-call options

mapper.Map<OrderDto>(order, opt =>
{
    opt.Items["UserId"] = userId;          // available as ctx.Items in resolvers / converters
    opt.AfterMap((src, dest) => { /* ... */ });
});

Value transformers

cfg.ValueTransformers.Add<string>(s => s.Trim());            // global
cfg.CreateMap<A, B>().AddTransform<string>(s => s.ToUpper()); // per map

IQueryable projection (Entity Framework Core)

List<OrderDto> dtos = await db.Orders
    .Where(o => o.Status == OrderStatus.Shipped)
    .ProjectTo<OrderDto>(mapper.ConfigurationProvider)
    .ToListAsync();

ProjectTo builds a Select expression from the maps, so only the needed columns are queried. Only expression-based configuration can be translated: conventions, flattening, MapFrom(s => ...), ConstructUsing(s => new ...) and ConvertUsing(s => ...). Members that use delegates, resolvers or value converters raise a clear error; Ignore() them or rewrite them as expressions. You can inspect the generated expression with config.BuildProjection<Order, OrderDto>().

Query parameters

Variables captured in MapFrom expressions become query parameters, so one map can depend on per-request values:

string currentUser = "";   // captured in the map, replaced per query
cfg.CreateMap<Post, PostDto>()
   .ForMember(d => d.IsMine, o => o.MapFrom(s => s.Author == currentUser));

var posts = db.Posts.ProjectTo<PostDto>(config, new { currentUser = User.Identity!.Name });
// or: ProjectTo<PostDto>(config, new Dictionary<string, object> { ["currentUser"] = name })

EF Core sees the value as a SQL parameter, so the query plan is reused across users.

Explicit expansion

Large or expensive members can be left out of projections unless the caller asks for them:

cfg.CreateMap<Author, AuthorDto>()
   .ForMember(d => d.Books, o => o.ExplicitExpansion());

db.Authors.ProjectTo<AuthorDto>(config);                    // no Books column or join
db.Authors.ProjectTo<AuthorDto>(config, null, a => a.Books); // Books included
db.Authors.ProjectTo<AuthorDto>(config, null, "Books.Reviews"); // nested paths as strings

mapper.Map() always maps explicitly expanded members; the option only affects ProjectTo.

Inspecting a configuration

TypeMap map = config.FindTypeMapFor<Order, OrderDto>();
foreach (var pm in map.PropertyMaps)
    Console.WriteLine($"{pm.DestinationName} <- {string.Join(".", pm.SourceMembers.Select(m => m.Name))}");
string[] unmapped = map.GetUnmappedPropertyNames();

Errors

  • MapperConfigurationException: invalid configuration (duplicate maps, unmapped members, bad Include, unknown source path). Errors lists every problem.
  • MappingException: a mapping failed at runtime. The message names the source/destination types and the member, and InnerException holds the original error.

Not included

Expression mapping (translating expressions written against DTOs), IDataReader mapping and runtime-generated proxies for interface destination types are not part of ConventionMapper; map to concrete classes instead.

Trimming and Native AOT

The runtime ConventionMapper package uses reflection and runtime code generation, so it is not compatible with trimming or Native AOT; for those apps use ConventionMapper.SourceGenerator, which generates plain C# with no reflection. The configuration constructors and AddConventionMapper are marked [RequiresUnreferencedCode] and [RequiresDynamicCode], so trimmed and AOT apps get a clear build warning instead of failing at runtime.

Samples

samples/ConventionMapper.Sample.WebApi is an ASP.NET Core minimal API on EF Core (in-memory) that shows ProjectTo with a query parameter and explicit expansion, mapper.Patch, collection merging by key and Explain() in one small app:

dotnet run --project samples/ConventionMapper.Sample.WebApi

samples/ConventionMapper.Sample.Aot is a Native AOT console app that uses only build-time mapping:

dotnet publish samples/ConventionMapper.Sample.Aot -c Release -r linux-x64 -o out && ./out/ConventionMapper.Sample.Aot

Building and publishing

./build.sh 1.2.3      # or ./build.ps1 -Version 1.2.3 (defaults to the version in the .csproj files)

This restores, builds, runs the tests and writes the ConventionMapper, ConventionMapper.SourceGenerator, ConventionMapper.Testing and ConventionMapper.Testing.EntityFrameworkCore packages (+ .snupkg) to ./artifacts. Every push to main publishes <version>-preview.<run> packages to nuget.org, and a v<version> tag (for example v1.2.3) publishes the release through trusted publishing (see .github/workflows/publish.yml). The release checklist is in RELEASING.md.

Contributing

Issues and pull requests are welcome; see CONTRIBUTING.md. The design rules and why they exist are in docs/adr, and planned work in ROADMAP.md. Report security problems privately as described in SECURITY.md.

Changelog

See CHANGELOG.md.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.10.0 64 10/27/2026
1.9.1 111 10/27/2026
1.9.0 82 10/27/2026
1.8.0 122 10/26/2026
1.7.2 58 10/26/2026
1.7.1 63 10/26/2026
1.7.0 135 10/26/2026
1.6.0 101 10/23/2026
1.5.0 108 10/23/2026
1.4.0 77 10/23/2026
1.3.0 73 10/23/2026
1.2.0 73 10/23/2026
1.1.0 111 10/23/2026
1.0.0 77 10/23/2026

## 1.10.0 (unreleased)

`ReverseMap()` for enum maps, plus database-level checks, honest acknowledgements and version safety from adopting 1.9 in a large scaffolded codebase.

**Upgrading:**
- New warnings can appear on existing code: CM0034 (a copied `false` or `0` replaced by a database default) and CM0035 (a `[MapReviewed]` entry that acknowledges nothing, such as entries added for the 1.9.0 CM0028 false positive). With warnings as errors, check before upgrading.
- ConventionMapper.Testing now fails when the mapper was generated by another major or minor version of the generator. Reference the same version of both packages.

### Runtime package
- **`ReverseMap()` for enum maps:** `ConvertUsingEnumMapping(o => o.MapValue(Color.Purple, ColorDto.Violet).ReverseMap())` also creates `ColorDto` → `Color` with the same matching (by name, case-insensitive or by value) and every override inverted. `ReverseMap(fallback: Color.Red)` sets the reverse map's fallback (a forward `Fallback` can't be inverted). Two overrides onto the same value can't be inverted: validation reports it and mapping that value back throws. `ReverseMap()` on the map itself, before or after `ConvertUsingEnumMapping`, inverts the mapping too, matching AutoMapper.Extensions.EnumMapping.

### Source generator
- **CM0034 (warning):** a create method writes a non-nullable `bool`, number or enum of an EF Core entity whose column has a database default other than the CLR default (`HasDefaultValueSql("((1))")`, `HasDefaultValue(1)`). EF Core leaves a property holding `false` or `0` out of the insert, so the row silently gets the default instead of the copied value. Fix by making the property nullable or configuring `HasSentinel`, or acknowledge it with `[MapReviewed]`. Strings, `DateTime`, `Guid`, nullable members, keys, members with `HasSentinel` and defaults equal to the CLR default (`((0))`) are not reported.
- **CM0035 (warning):** a `[MapReviewed]` entry on a method or the mapper class that silences nothing: no method that uses it (the method, methods inheriting it through `[MapInherit]`, or every method for the class) writes the member as a key, a database-generated value, a navigation, a column with a database default or a string that can be too long. Stale acknowledgements would hide a future problem.
- **CM0036 (info):** a create method copies a value that can be null into a nullable entity member whose column has a database default; EF Core leaves null out of the insert, so the default is stored. Often intended, so information; acknowledge it with `[MapReviewed("X", For = ReviewKind.DatabaseDefault)]`.
- **`[MapReviewed(..., For = ReviewKind.Key)]`**: an acknowledgement can name the kinds of warning it covers (`Key`, `GeneratedValue`, `Navigation`, `DatabaseDefault`, `Length`; `All` by default, as before), so acknowledging a key write no longer hides a length problem on the same member. CM0035 tracks each attribute and its kinds.
- **`[MapReviewed(..., Reason = "...")]`**: the data-flow report lists every acknowledgement with its kinds and reason in a new "Acknowledged with [MapReviewed]" section.
- The EF model reader also reads `HasDefaultValue(...)` and `HasSentinel(...)`.
- Scaffolded-model tests: code shaped exactly like `dotnet ef dbcontext scaffold` output, with every diagnostic of a typical row copy listed, so false positives on scaffolded models fail the build of the library.

### Testing
- **Version check:** `MapperAssert` methods that read generated metadata (`WriteSets`, `Lineage`, `GeneratedCode`) fail with both versions when the generator's major or minor version differs from ConventionMapper.Testing's, instead of failing in confusing ways.
- `SampleDataOptions.Clone()`: an independent copy, to add values for one run.

### ConventionMapper.Testing.EntityFrameworkCore (new package)
- **`DatabaseAssert.SavesRoundTrip(context, map, source)`**: maps, inserts inside a rolled-back transaction, reads back, and fails for every value the database changed: a default that replaced a copied `false` (CM0034 in action), cut text, rounded numbers; failed inserts carry the database's message.
- **`DatabaseAssert.AllMappersSaveRoundTrip(context, typeof(Mappings))`**: `SavesRoundTrip` for every generated create method that returns an entity of the context, with sample data and again with `false` and `0` values (the ones a database default replaces). Foreign keys aren't checked inside the rolled-back transaction, so no rows need seeding; CHECK constraints, NOT NULL and unique indexes stay on, so a value the database rejects still fails.
- **`DatabaseAssert.ModelMatchesDatabase(context)`**: compares the EF Core model's tables and columns (existence, nullability, maximum length on SQL Server, defaults) with the real schema, so a scaffolded model that drifted is found before the generator's warnings mislead. SQL Server and SQLite.
- **`DatabaseAssert.ExportSchema(context, "schema.json")`** and **`ModelMatchesSchema(context, "schema.json")`**: export the real schema once (from a copy of production), commit the file, and check the model against it in every test run, without a database.

### Build
- Running the workflow manually on `main` publishes `<next version>-preview.<run>` packages to nuget.org (unless that version is already released), so changes can be tried in a real codebase before the release tag. Previews are published on request because nuget.org keeps every version.
- A `sqlserver` CI job runs the database checks against a real SQL Server 2022 and is required before packing.
- After a release tag, the workflow opens a pull request merging `main` into every `feature/*` branch that doesn't contain it yet.
- Release checklist in [RELEASING.md](https://github.com/katragvb/Conventionmapper/blob/main/RELEASING.md), including trying the preview in a real codebase before tagging.
- Package validation baselines moved to 1.9.1; the new package validates its API across target frameworks from its first release.

Full history: CHANGELOG.md in this package, or https://github.com/katragvb/Conventionmapper/blob/main/CHANGELOG.md