Mapwright · AutoMapper migration reference

AutoMapper vs Mapwright, side by side

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.

1. The problem mapping solves

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.

2. Why AutoMapper exists

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.

3. The issues AutoMapper leaves open

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:

Silent drift (a correctness risk)

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.

Deferred, opt-in verification

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.

Runtime cost & AOT/trimming

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.

A runtime black box

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.

Over-eager "magic"

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.

Licensing direction

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.

4. How Mapwright solves each, with modern C#

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 issueModern-C# mechanism Mapwright usesResult
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.

5. Side-by-side at a glance

AutoMapperMapwright
When the mapping is resolvedAt runtime (reflection + cached plan)At compile time (source generator)
An unmapped property is…A silent null, maybe caught by a testA compiler warning (MW0001), promotable to an error
A stale ignore/rename entry is…Never caughtA build error (MW0003)
VerificationAssertConfigurationIsValid(), if a test calls itThe compiler, every build
Runtime machineryReflection engine + config cacheNone (attributes only)
Native AOT / trimmingProblematicSafe
Can you step through a mapping?NoYes — it's a .g.cs file
DIIMapper injected, registered at startupNothing — call the static method
Config to maintainProfiles + CreateMap/ForMember3–5 attribute lines per entity
LicenseMoving to commercial (verify terms)MIT, zero dependencies

6. Statement-by-statement replacement guide

Every AutoMapper construct has a direct Mapwright equivalent. The table is the cheat sheet; the worked pairs below show each one.

AutoMapperMapwright
CreateMap<S, D>() in a Profilestatic 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 destAutomatic (GetValueOrDefault(), visibly, in generated code)
IMapper injected via DINothing — call the static method directly
AssertConfigurationIsValid() testThe compiler (MW0001), promotable to an error

6.1 The basic map + ignore list

AutoMapper
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));
Mapwright
[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

6.2 The call site

AutoMapper
private readonly IMapper _mapper;   // injected

var entity = _mapper.Map<ProductEntity>(product);
Mapwright
// no field, no constructor parameter, no mock in tests

var entity = CatalogMapper.ToEntity(product);

6.3 Rename, computed value, in-place update, collection, projection

ScenarioAutoMapperMapwright
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());

6.4 Deleting the old world

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.

7. A complete sample app (with real output)

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

7.1 The mapping layer (the whole thing)

[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
}

7.2 What the generator writes (excerpt)

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;
}

7.3 The program output (actual run)

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.
Read that output against the declarations: the 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.

8. The diagnostics that replace runtime checks

These are what turn "silent null in production" into "red squiggle in the IDE." They run on every build; no test calls them.

IdSeverityMeaning
MW0001WarningA destination property is not mapped — map it, [MapIgnore] it, or set it in [AfterMap]. This is the compile-time replacement for AssertConfigurationIsValid().
MW0002InfoA source property is never read — [MapIgnoreSource] documents a deliberate one-way field.
MW0003ErrorAn ignore/rename names a property that doesn't exist — stale configuration, caught at build.
MW0004ErrorNo conversion between two matched properties' types.
MW0005ErrorThe method signature isn't a recognized mapping shape.
MW0006WarningAn in-place copy can't set an init-only property.
MW0007ErrorA projection would recurse forever.
MW0008ErrorA collection map's element type has no object map declared.
MW0009ErrorAn [AfterMap] method is missing, has the wrong signature, or sits on a projection.
MW0010ErrorDestination has no usable public constructor.
MW0011WarningBy-name enum map: a source member has no same-named destination.
MW0012ErrorA required member is neither mapped nor constructed.
MW0013ErrorA [MapDerivedType] pair doesn't derive from the base pair, or has no map.
MW0014ErrorA [BeforeMap] method is missing, has the wrong signature, or sits on a projection.
MW0015ErrorA generic mapping method isn't a recognized dispatch shape.
MW0016ErrorA merged collection has no recognizable key; name one with [MergeCollection(..., Key = ...)].
MW0017ErrorThe merge key is missing on one element type, or its types differ.
MW0018Error[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

9. Design boundaries

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.

Keep going: the beginner's guide explains how source-generated mapping works from zero; the production case study walks a real eight-entity AutoMapper profile through the migration end to end; the three-way comparison puts Mapwright, Mapperly and AutoMapper side by side, including where Mapwright loses; and the runnable proof is samples/CatalogApi.Sample in the repo.
Mapwright · MIT licensed · github.com/lodestar-labs/Mapwright
Sample source and output on this page are from samples/CatalogApi.Sample, run verbatim. AutoMapper licensing note reflects a 2025 announcement — verify current terms for your usage.