Mapwright · Case study

Replacing AutoMapper, at compile time

How a production catalog API deleted its AutoMapper configuration — profiles, ignore lists, the AssertConfigurationIsValid() test, all of it — and got the same mappings back as generated, readable, compiler-verified C#.

The starting point

The subject of this case study is a production REST API that manages a scientific reference-code catalog: code tables, code types, cross-table relations, synonyms. Names below are neutralized, but the shapes, the AutoMapper usage, and every migration decision are real.

The API kept two parallel models, as most layered .NET systems do:

One static MapperFactory owned every CreateMap. Three maps per entity, eight entities — and the two failure modes every AutoMapper codebase knows:

// The old world: runtime configuration, checked (maybe) by a test.
var config = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<Domain.Code, Entities.CodeEntity>()
       .ForMember(d => d.User,        o => o.Ignore())
       .ForMember(d => d.Created,     o => o.Ignore())
       .ForMember(d => d.Modified,    o => o.Ignore())
       .ForMember(d => d.ParentCodes, o => o.Ignore());

    cfg.CreateMap<Entities.CodeEntity, Domain.Code>();

    // Self-map: scalar copy onto the EF-tracked instance during updates.
    cfg.CreateMap<Entities.CodeEntity, Entities.CodeEntity>()
       .ForMember(d => d.User,    o => o.Ignore())
       .ForMember(d => d.Created, o => o.Ignore());
    // ... five more entities of the same ...
});
Failure mode #1 — silent drift. Rename a domain property and the convention match quietly disappears: the entity column ships as null. Nothing fails until data is wrong.

Failure mode #2 — deferred verification. AssertConfigurationIsValid() catches unmapped members — at runtime, in a test somebody must remember to keep honest, with ignore lists that rot invisibly as models evolve.
Before you transcribe anything by hand: much of the mechanical work below can be done for you. The Mapwright.Migration package is a build-time-only analyzer that reads a Profile and writes the equivalent [Mapper] class into a new file beside it — CreateMap declarations, ignore lists, renames and flattened paths — leaving your Profile untouched and flagging everything it cannot translate as a TODO(mapwright) rather than guessing. The case study below was migrated by hand, and reads the same either way; see Migrating with the analyzer for what it converts and what it deliberately hands back to you.

The Mapwright shape

Mapwright replaces the configuration object with declarations. Each mapping is a static partial method on a [Mapper] class; a Roslyn incremental source generator writes the body while the compiler runs:

[Mapper]
public static partial class CatalogMapper
{
    // Domain -> entity. The same four ignores the old profile declared —
    // now verified against the entity type on every build.
    [MapIgnore(nameof(CodeEntity.User), nameof(CodeEntity.Created),
               nameof(CodeEntity.Modified), nameof(CodeEntity.ParentCodes))]
    public static partial CodeEntity ToEntity(Code source);

    // Entity -> domain. bool? columns collapse to the domain's non-nullable bools.
    [MapIgnoreSource(nameof(CodeEntity.User), nameof(CodeEntity.ParentCodes))]
    public static partial Code ToDomain(CodeEntity source);

    // The self-map, as an in-place copy for the Repository.Update pattern.
    [MapIgnore(nameof(CodeEntity.User), nameof(CodeEntity.Created), nameof(CodeEntity.Modified))]
    public static partial void CopyScalars(CodeEntity source, CodeEntity target);

    // The ProjectTo replacement — EF Core translates this to SQL.
    public static partial Expression<Func<CodeEntity, CodeSummary>> SummaryProjection();

    public static partial List<CodeEntity> ToEntities(IEnumerable<Code> source);
}

And this is what the generator emits — not IL, not a runtime plan, but a .g.cs file that reads exactly like the code a careful engineer writes by hand (because that is the standard it was built against):

// <auto-generated by Mapwright — the mapping code below is yours to read, debug, and step through />
public static partial Code ToDomain(CodeEntity source)
{
    ArgumentNullException.ThrowIfNull(source);
    var result = new Code
    {
        CodeID      = source.CodeId,              // ID ↔ Id casing: matched automatically
        CodeTypeID  = source.CodeTypeId,
        Value       = source.Value,
        Description = source.Description,
        Visibility  = source.Visibility.GetValueOrDefault(),  // bool? → bool, visibly
        Deprecated  = source.Deprecated.GetValueOrDefault(),
        LongDescription = source.LongDescription,
        Created     = source.Created,             // inherited record members included
        Modified    = source.Modified,
    };
    return result;
}
The core trade. AutoMapper hides this code inside a runtime engine; the migration's hand-written phase proved the team wanted to see it. Mapwright generates precisely that hand-written shape — with none of the hand-maintenance: eight entities × three maps regenerate on every build, and drift is a diagnostic, not an incident.

Feature-by-feature: where each AutoMapper behavior went

AutoMapperMapwrightWhat changed
CreateMap<S, D>() static partial D Map(S source); Configuration became a declaration; the map is a plain method call — no IMapper to inject, nothing in DI.
Convention matching Case-insensitive name match, inherited members included CodeID → CodeId still needs zero config; a match that stops matching becomes MW0001 instead of a silent null.
ForMember(d => d.P, o => o.Ignore()) [MapIgnore(nameof(D.P))] Same intent, but a stale entry is a build error (MW0003) — ignore lists cannot rot.
ForMember(d => d.P, o => o.MapFrom(s => s.Q)) [MapProperty("Q", "P")], or [AfterMap] for computed values Renames stay declarative; logic moves into a named, debuggable method in your own class.
Self-map + Map(src, dest) static partial void Copy(S source, D target); In-place update of the EF-tracked instance, with init-only members flagged (MW0006) rather than half-copied.
Null substitution (bool? column → bool) Generated GetValueOrDefault() The null-collapsing behavior is visible in the generated file instead of buried in engine semantics.
ProjectTo<D>(queryable) static partial Expression<Func<S, D>> Projection(); q.Select(M.Projection()) — the SQL-shaping expression is inspectable, nested maps inlined, cycles rejected at build time (MW0007).
AssertConfigurationIsValid() The compiler The verification test was deleted. MW0001 fires in the IDE on every keystroke; .editorconfig can promote it to an error.

The verification story, before and after

Before — runtime, opt-in

A MappingCoverageTests fixture called the verifier and maintained string lists of "unmapped by design" properties per map. It ran when CI ran, verified what the lists said, and the lists themselves were unverifiable — a removed property lingered in the ignore list forever, and a new property was caught only after a full test cycle.

After — compile time, always on

Add a property to CodeEntity and the IDE underlines ToEntity within seconds: MW0001: Destination property 'Sponsor' is not mapped. Remove a property that an ignore list still names, and the build fails with MW0003. The coverage test fixture is gone; there is nothing left for it to check.

What the numbers looked like

AutoMapperHand-written (interim)Mapwright
Mapping configuration/code to maintain~190-line MapperFactory~45 lines × 8 entity files3–5 declaration lines per entity
Unmapped property discoveredRuntime test (if written)Runtime verifier testIDE squiggle, build warning/error
Stale ignore entry discoveredNeverNeverBuild error
Runtime machineryReflection engine + config cacheNoneNone (attributes only; AOT/trim safe)
Can you step through a mapping?NoYesYes — it's a .g.cs file

The migration, step by step

  1. Install. Add the Mapwright attributes package and the Mapwright.Generator analyzer package to the project that owns the mappings.
  2. Inventory the profiles. Each CreateMap<S,D> becomes one static partial declaration; each ForMember(...Ignore()) list becomes a [MapIgnore(...)] with nameof. Direction matters: the case study's domain→entity and entity→domain maps carried different ignore lists, and kept them.
  3. Let MW0001 drive. Build. Every warning is a decision the old profile made implicitly — map it, ignore it, or hand it to an [AfterMap]. When the build is clean, the mapping layer is provably total.
  4. Rewrite call sites mechanically. _mapper.Map<CodeEntity>(code)CatalogMapper.ToEntity(code); _mapper.Map(incoming, tracked)CatalogMapper.CopyScalars(incoming, tracked); ProjectTo<CodeSummary>().Select(CatalogMapper.SummaryProjection()). Then delete the IMapper constructor parameters and DI registrations.
  5. Delete. The MapperFactory, the AutoMapper package references, and the configuration-validity test. The compiler owns that job now.
  6. Tighten. dotnet_diagnostic.MW0001.severity = error in .editorconfig once the team trusts the workflow — full AutoMapper-strictness with zero runtime cost.

Where the boundaries are

Since this case study was written, Mapwright has reached full Mapperly parity: constructor and positional-record mapping, enums by name, dictionaries and sets, flattening and unflattening, derived types, deep cloning, hand-written converters — including instance methods on instance mappers, so constructor-injected services convert values — and generic Map<TSource, TTarget> dispatch. The boundaries that remain are deliberate: projections never contain hooks or service calls (so EF can always translate them), and the mapper class itself stays non-generic — generic methods dispatch across its declared maps. For anything the generator shouldn't guess, [BeforeMap]/[AfterMap] and hand-written methods are first-class escape hatches.

Getting started

git clone https://github.com/lodestar-labs/Mapwright.git
cd Mapwright
dotnet test          # 86 tests — behavior over real generated mappers + diagnostics

Then read the generated mappers for the case-study shapes under tests/Mapwright.Tests/obj/generated/ after a build — the fastest way to convince yourself there is no magic left.

Mapwright · MIT licensed · github.com/lodestar-labs/Mapwright
Case-study shapes are anonymized from a production system; the patterns, decisions, and diagnostics are real.