Why object mapping exists, the specific problems AutoMapper leaves open, and how Mapwright closes them with modern C# — plus a statement-by-statement replacement guide and a complete sample app whose real output is printed here, so you can see it works without deploying anything.
Almost every layered application keeps more than one shape for the "same" data, and has to convert between them constantly.
A typical API has an immutable domain model and a mutable, database-shaped
persistence entity (nullable columns, audit fields like Created/User,
navigation properties), plus DTOs for what actually crosses the wire. Converting
between them — mapping — is trivial once:
var dto = new ProductDto
{
Id = entity.ProductId,
Name = entity.Name,
Price = entity.Price,
IsActive = entity.IsActive ?? false,
};
The trouble is that a real app has that same block for dozens of types, written by different people over months, and every one rots the same way: someone adds a property and forgets to map it, or renames one and the assignment silently maps nothing. Both AutoMapper and Mapwright exist to take that mechanical code off your hands — but they do it in opposite ways, and that difference is the entire story here.
AutoMapper is the long-standing, hugely popular convention-based
mapper for .NET. Instead of writing the assignment block, you configure a mapping once and
call a generic Map everywhere:
// configure once (in a Profile)
CreateMap<ProductEntity, ProductDto>();
// register once
services.AddAutoMapper(typeof(ProductProfile));
// use everywhere (IMapper injected)
ProductDto dto = _mapper.Map<ProductDto>(entity);
Its appeal is real: same-named properties (Id, Name, Price)
are copied automatically by convention, so the boring 90% of a mapping writes itself, and one
CreateMap serves every call site. That convenience is why it became a default.
Under the hood, at application start AutoMapper inspects your profiles with reflection,
compiles an internal execution plan per CreateMap, caches it, and every later
Map() runs that cached plan. None of it is visible to the C# compiler — the compiler has
no idea what CreateMap<A, B>() will do; it finds out only when the program runs.
That single fact is the root of everything in the next section.
AutoMapper is not "bad" — it has mapped an enormous amount of production .NET successfully. But the runtime, convention-driven model has specific, well-known costs:
Rename or remove a property on either side and the convention match
just disappears — quietly. The destination ships as null/default.
Nothing fails at compile time; the bug surfaces later as wrong data.
AssertConfigurationIsValid() catches unmapped members —
but only at runtime, only if a test remembers to call it, with ignore lists that rot invisibly as
the models evolve.
Reflection, expression compilation and a config cache cost startup time and memory, and are fundamentally at odds with Native AOT and trimming — which more teams need for containers and cold-start latency.
When a mapping misbehaves there is no method body to breakpoint — the actual copying lives inside the engine, not your code. Debugging a mismapping means reverse-engineering the engine's behavior.
Automatic flattening and convention matching can silently bind a
destination member to an unintended source (a nested Customer.Name flattening
onto CustomerName), a mapping you never asked for and won't notice.
AutoMapper's maintainer announced in 2025 a move toward commercial licensing for the library. If that matters to your organization, verify the current terms for your usage before adopting or upgrading.
Five of these six are consequences of resolving the mapping at runtime. The natural fix is to resolve it while the code compiles — so a bad mapping is a build error instead of a production incident. That is exactly what a source generator makes possible, and it's what Mapwright is.
Mapwright is a compile-time mapper. You still declare what maps to what — you don't hand-write the assignments — but instead of a runtime engine reading that declaration, a Roslyn incremental source generator reads it while your project builds and writes the mapping method for you as ordinary C#, before the program ever runs.
| AutoMapper issue | Modern-C# mechanism Mapwright uses | Result |
|---|---|---|
| Silent drift | Compiler diagnostics from a source generator. An unmapped destination property is MW0001 in the IDE on every keystroke; promote it to an error in .editorconfig. |
A rename that used to ship null is now a red squiggle before you run. |
| Deferred verification | The build is the verifier. There is no AssertConfigurationIsValid() to remember — the check runs every compile. |
Verification can't be forgotten; there's no test to keep honest. |
| Config rot (stale ignore lists) | nameof + generator validation. Ignore/rename targets are checked against the real types; a name that no longer exists is MW0003, a build error. |
Ignore lists literally cannot rot. |
| Runtime cost / AOT | No runtime engine. The Mapwright package is attributes only; the generated code is plain assignments. No reflection, no expression compilation, no cache warm-up. |
Native AOT and trimming just work; the only allocation is the destination object. |
| Black box | partial methods filled by the generator. The implementation lands in a readable .g.cs file you can open, diff, and breakpoint. |
There is no engine to reverse-engineer at 2 a.m. |
| Over-eager magic | Explicit, per-member matching. Case-insensitive same-name matching only; real renames are declared with [MapProperty]. No automatic flattening guesses. |
The generated code shows exactly what maps to what — no surprise sources. |
| Licensing | MIT-licensed, dependency-free. The runtime package has zero dependencies and executes nothing. | No commercial-license question to track. |
Two more modern-C# details make the ergonomics work: partial methods let half a
method live in your file and half in the generated file, stitched into one class by the compiler; and
the generator emits EF-translatable Expression trees for projections, so
the ProjectTo scenario keeps working without the runtime engine.
| AutoMapper | Mapwright | |
|---|---|---|
| When the mapping is resolved | At runtime (reflection + cached plan) | At compile time (source generator) |
| An unmapped property is… | A silent null, maybe caught by a test | A compiler warning (MW0001), promotable to an error |
| A stale ignore/rename entry is… | Never caught | A build error (MW0003) |
| Verification | AssertConfigurationIsValid(), if a test calls it | The compiler, every build |
| Runtime machinery | Reflection engine + config cache | None (attributes only) |
| Native AOT / trimming | Problematic | Safe |
| Can you step through a mapping? | No | Yes — it's a .g.cs file |
| DI | IMapper injected, registered at startup | Nothing — call the static method |
| Config to maintain | Profiles + CreateMap/ForMember | 3–5 attribute lines per entity |
| License | Moving to commercial (verify terms) | MIT, zero dependencies |
Every AutoMapper construct has a direct Mapwright equivalent. The table is the cheat sheet; the worked pairs below show each one.
| AutoMapper | Mapwright |
|---|---|
CreateMap<S, D>() in a Profile | static partial D Map(S source); in a [Mapper] class |
ForMember(d => d.P, o => o.Ignore()) | [MapIgnore(nameof(D.P))] |
ForMember(d => d.P, o => o.MapFrom(s => s.Q)) (rename) | [MapProperty(nameof(S.Q), nameof(D.P))] |
ForMember(d => d.P, o => o.MapFrom(s => s.A + s.B)) (computed) | [AfterMap(nameof(Fix))] + a plain method |
Self-map + Map(src, dest) (in-place update) | static partial void Copy(S source, D target); |
Map<List<D>>(list) / Map<D[]> | static partial List<D> Map(IEnumerable<S> source); |
ProjectTo<D>(queryable) | static partial Expression<Func<S,D>> Projection(); + .Select(...) |
| Nullable source → non-nullable dest | Automatic (GetValueOrDefault(), visibly, in generated code) |
IMapper injected via DI | Nothing — call the static method directly |
AssertConfigurationIsValid() test | The compiler (MW0001), promotable to an error |
public class ProductProfile : Profile
{
public ProductProfile()
{
CreateMap<Product, ProductEntity>()
.ForMember(d => d.User, o => o.Ignore())
.ForMember(d => d.Created, o => o.Ignore())
.ForMember(d => d.Modified, o => o.Ignore())
.ForMember(d => d.Tags, o => o.Ignore());
}
}
services.AddAutoMapper(typeof(ProductProfile));
[Mapper]
public static partial class CatalogMapper
{
[MapIgnore(nameof(ProductEntity.User),
nameof(ProductEntity.Created),
nameof(ProductEntity.Modified),
nameof(ProductEntity.Tags))]
public static partial ProductEntity ToEntity(Product source);
}
// no DI registration — nothing to add to Program.cs
private readonly IMapper _mapper; // injected
var entity = _mapper.Map<ProductEntity>(product);
// no field, no constructor parameter, no mock in tests
var entity = CatalogMapper.ToEntity(product);
| Scenario | AutoMapper | Mapwright |
|---|---|---|
| Rename | .ForMember(d => d.ProductCode, o => o.MapFrom(s => s.Sku)) |
[MapProperty(nameof(LineEntity.Sku), nameof(LineDto.ProductCode))] |
| Computed / stamped value | .ForMember(d => d.User, o => o.MapFrom(_ => "provisioning")) |
[AfterMap(nameof(Stamp))] + static void Stamp(S s, D d) { d.User = "provisioning"; } |
| In-place update (EF-tracked) | _mapper.Map(incoming, tracked); |
CatalogMapper.CopyScalars(incoming, tracked); |
| Collection | _mapper.Map<List<ProductEntity>>(products); |
CatalogMapper.ToEntities(products); (a declared List<D> M(IEnumerable<S>)) |
| Server-side projection | query.ProjectTo<ProductSummary>(_config); |
query.Select(CatalogMapper.SummaryProjection()); |
Once the declarations compile clean, delete the AutoMapper Profile classes, the
services.AddAutoMapper(...) registration, every IMapper constructor
parameter, and the AssertConfigurationIsValid() test — the compiler owns that job now.
The full production walkthrough of exactly this migration is the
Replacing AutoMapper case study.
The repository ships a runnable console app at samples/CatalogApi.Sample that exercises
every shape above. Everything below is the actual source and the actual program output
— nothing is deployed; it runs locally with one command:
git clone https://github.com/lodestar-labs/Mapwright.git
cd Mapwright
dotnet run --project samples/CatalogApi.Sample
[Mapper]
public static partial class CatalogMapper
{
[MapIgnore(nameof(ProductEntity.User), nameof(ProductEntity.Created),
nameof(ProductEntity.Modified), nameof(ProductEntity.Tags))]
public static partial ProductEntity ToEntity(Product source);
[MapIgnoreSource(nameof(ProductEntity.User), nameof(ProductEntity.Tags))]
public static partial Product ToDomain(ProductEntity source);
[MapIgnore(nameof(ProductEntity.User), nameof(ProductEntity.Created), nameof(ProductEntity.Modified))]
public static partial void CopyScalars(ProductEntity source, ProductEntity target);
public static partial Expression<Func<ProductEntity, ProductSummary>> SummaryProjection();
public static partial List<ProductEntity> ToEntities(IEnumerable<Product> source);
}
[Mapper]
public static partial class OrderMapper
{
public static partial CustomerDto ToDto(CustomerEntity source);
[MapProperty(nameof(LineEntity.Sku), nameof(LineDto.ProductCode))]
public static partial LineDto ToDto(LineEntity source);
public static partial OrderDto ToDto(OrderEntity source); // nested Customer + Lines auto-routed
}
These land in obj/.../generated/ at build time — ordinary C# you can read and
breakpoint. The only edit below is readability: the generator emits fully qualified names
(global::System.Linq.Enumerable, global::CatalogApi.Sample.OrderDto),
shortened here to Enumerable / OrderDto. Note the
bool? → bool collapse and the nested/collection routing are written out plainly:
// <auto-generated by Mapwright — the mapping code below is yours to read, debug, and step through />
public static partial Product ToDomain(ProductEntity source)
{
ArgumentNullException.ThrowIfNull(source);
var result = new Product
{
ProductID = source.ProductId,
CategoryID = source.CategoryId,
Name = source.Name,
Price = source.Price,
IsActive = source.IsActive.GetValueOrDefault(), // bool? → bool, visibly
Created = source.Created,
Modified = source.Modified,
};
return result;
}
public static partial OrderDto ToDto(OrderEntity source)
{
ArgumentNullException.ThrowIfNull(source);
var result = new OrderDto
{
OrderId = source.OrderId,
Number = source.Number,
Customer = source.Customer is null ? null! : ToDto(source.Customer),
Lines = source.Lines is null ? null! : Enumerable.ToList(Enumerable.Select(source.Lines, ToDto)),
};
return result;
}
Mapwright sample — every mapping below is generated C#, verified by the compiler.
=== 1. Entity -> domain (ToDomain) ===
ProductID=42 Name=Widget Price=$19.95 IsActive=True Created=2025-06-01
=== 2. Domain -> entity (ToEntity) ===
ProductId=100 Name=Gadget IsActive=True User=<null> Created=<null>
=== 3. In-place update (CopyScalars) — audit fields preserved ===
Name=New name Price=$12.49 (User still 'auditor', Created still 2024-01-01 — untouched)
=== 4. Collection map (ToEntities) ===
mapped 2 entities: 1:A, 2:B
=== 5. Projection (SummaryProjection) applied in-memory here, EF-translatable in a query ===
1 summary: ProductId=42 Name=Widget Price=$19.95
=== 6. Nested map + collection + rename (Order -> OrderDto) ===
Order ORD-5001 for Acme Corp
line 1: ProductCode=SKU-A (from Sku), Qty=3
line 2: ProductCode=SKU-B (from Sku), Qty=1
=== 7. AfterMap (ToNewEntity stamps audit fields) ===
Name=Gadget User=provisioning Created=2026-01-01 (stamped by AfterMap)
All mappings ran. Nothing was configured at startup, and no IMapper was injected.
bool? IsActive column became a real
True; CopyScalars updated name/price while leaving the audit fields on the
tracked entity untouched; the collection, the EF-translatable projection, the nested order with its
renamed Sku → ProductCode, and the AfterMap stamp all did exactly what the
attributes said — and every line was verified by the compiler before it ran.
These are what turn "silent null in production" into "red squiggle in the IDE." They
run on every build; no test calls them.
| Id | Severity | Meaning |
|---|---|---|
MW0001 | Warning | A destination property is not mapped — map it, [MapIgnore] it, or set it in [AfterMap]. This is the compile-time replacement for AssertConfigurationIsValid(). |
MW0002 | Info | A source property is never read — [MapIgnoreSource] documents a deliberate one-way field. |
MW0003 | Error | An ignore/rename names a property that doesn't exist — stale configuration, caught at build. |
MW0004 | Error | No conversion between two matched properties' types. |
MW0005 | Error | The method signature isn't a recognized mapping shape. |
MW0006 | Warning | An in-place copy can't set an init-only property. |
MW0007 | Error | A projection would recurse forever. |
MW0008 | Error | A collection map's element type has no object map declared. |
MW0009 | Error | An [AfterMap] method is missing, has the wrong signature, or sits on a projection. |
MW0010 | Error | Destination has no usable public constructor. |
MW0011 | Warning | By-name enum map: a source member has no same-named destination. |
MW0012 | Error | A required member is neither mapped nor constructed. |
MW0013 | Error | A [MapDerivedType] pair doesn't derive from the base pair, or has no map. |
MW0014 | Error | A [BeforeMap] method is missing, has the wrong signature, or sits on a projection. |
MW0015 | Error | A generic mapping method isn't a recognized dispatch shape. |
MW0016 | Error | A merged collection has no recognizable key; name one with [MergeCollection(..., Key = ...)]. |
MW0017 | Error | The merge key is missing on one element type, or its types differ. |
MW0018 | Error | [MergeCollection] names something that can't be merged in place. |
Promote the main one to a hard error once your team trusts the workflow — strictly stronger than AutoMapper's opt-in test ever was:
# .editorconfig
dotnet_diagnostic.MW0001.severity = error
Mapwright covers the full everyday mapping surface — constructor and positional-record mapping,
enums by value or by name, collections, sets and dictionaries, flattening and unflattening, derived
types, deep cloning, hand-written and DI-resolved converters, and generic
Map<TSource, TTarget> dispatch. The boundaries that remain are deliberate choices,
not gaps:
The trade for those boundaries is the whole point of this document: within its scope, every mapping is plain C# the compiler has already checked, drift is a build error rather than a production bug, and there's no runtime engine, no DI, and no licensing question in the loop.
samples/CatalogApi.Sample in the repo.
samples/CatalogApi.Sample, run verbatim.
AutoMapper licensing note reflects a 2025 announcement — verify current terms for your usage.