ConventionMapper 1.10.0
dotnet add package ConventionMapper --version 1.10.0
NuGet\Install-Package ConventionMapper -Version 1.10.0
<PackageReference Include="ConventionMapper" Version="1.10.0" />
<PackageVersion Include="ConventionMapper" Version="1.10.0" />
<PackageReference Include="ConventionMapper" />
paket add ConventionMapper --version 1.10.0
#r "nuget: ConventionMapper, 1.10.0"
#:package ConventionMapper@1.10.0
#addin nuget:?package=ConventionMapper&version=1.10.0
#tool nuget:?package=ConventionMapper&version=1.10.0
ConventionMapper
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,
Getmethods, constructors and records map with no configuration. Profiles,CreateMap,ForMember,ReverseMap,ProjectTo,AssertConfigurationIsValidand 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.SourceGeneratorpackage 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
- One compiled delegate per map. The first time a map is used, it is compiled into a strongly typed expression tree.
Person → PersonDtobecomes the equivalent ofnew PersonDto { Id = src.Id, Name = src.Name, ... }. - Direct assignments, no boxing. Numbers, dates, enums, strings and flattened paths such as
Customer.Address.Cityare copied directly, with null-safe navigation. Conditions, pre-conditions and null substitutes compile to plainifchecks. Only members that genuinely need more (resolvers, custom conversions) call into the engine, member by member, so results are identical to the interpreter. - Nested maps and collections inlined. On the fast path, child maps are expanded in place and collections are filled by typed loops (
forover arrays andList<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. - 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 typedFunc<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. - Pooled context. Maps that need a
ResolutionContext(resolvers, conditions, per-call items) reuse one per thread instead of allocating one per call. Callcfg.DisableContextPooling()if a resolver keeps a reference to the context afterMapreturns. - Automatic fallback. Maps that need reference tracking (cycles,
PreserveReferences,MaxDepth), struct destinations andPatch()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 → OrderDtowith flattening (CustomerAddressCity) and a list of 5 lines. - Context:
Person → PersonDtowith aConditionthat reads theResolutionContext.
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 whenAssertConfigurationIsValid()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 fasterMap<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, translatingIgnore,MapFrommember paths andReverseMap, 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(...)throwsMapperConfigurationException, 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, andchanges.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, badInclude, unknown source path).Errorslists every problem.MappingException: a mapping failed at runtime. The message names the source/destination types and the member, andInnerExceptionholds 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 | Versions 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. |
-
.NETStandard 2.0
-
net8.0
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