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
- 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.
- 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 interpreterThe 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.
- 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.
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.
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.
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.
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.
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.
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 |
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 worksmapper.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.
cfg.CreateMap<Color, ColorDto>().ConvertUsingEnumMapping(o => o
.MapByName() // or MapByValue(), MapByName(ignoreCase: true)
.MapValue(Color.Purple, ColorDto.Violet)
.Fallback(ColorDto.Unknown)); // optionalWithout 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.
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.
Error mapping types.
Mapping types:
String -> Int32
Path:
ParentDto.Items[1].Count
MappingException.MemberPath holds the same path for logging.
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)]
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.
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);services.AddConventionMapper(cfg =>
{
cfg.CreateMap<Foo, FooDto>();
}, typeof(Program)); // scans the assembly containing ProgramThis 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.
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));| 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");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>(); // IMappingActionOther 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.
cfg.CreateMap<string, DateTime>().ConvertUsing(s => DateTime.Parse(s));
cfg.CreateMap<DateTime, string>().ConvertUsing<DateTimeToStringConverter>();
cfg.CreateMap(typeof(Source<>), typeof(Dest<>)).ConvertUsing(typeof(MyGenericConverter<,>));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 instancesIncludeBase<TBaseSource, TBaseDest>() works the other way round. Member configuration and before/after actions are inherited from base maps.
cfg.CreateMap(typeof(Page<>), typeof(PageDto<>));
var dto = mapper.Map<PageDto<UserDto>>(page);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.
[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.
mapper.Map(source, existingDestination); // updates in place; nested objects and collections are reusedmapper.Map<OrderDto>(order, opt =>
{
opt.Items["UserId"] = userId; // available as ctx.Items in resolvers / converters
opt.AfterMap((src, dest) => { /* ... */ });
});cfg.ValueTransformers.Add<string>(s => s.Trim()); // global
cfg.CreateMap<A, B>().AddTransform<string>(s => s.ToUpper()); // per mapList<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>().
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.
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 stringsmapper.Map() always maps explicitly expanded members; the option only affects ProjectTo.
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();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.
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.
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/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
./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.
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.
See CHANGELOG.md.
MIT