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 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:
ID, non-nullable booleans, audit
timestamps inherited from a base record;Id, nullable database columns
(bool?, DateTime?), an audit User column, navigation collections,
and a few [NotMapped] relationship helpers.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 ...
});
null. Nothing fails until data is wrong.AssertConfigurationIsValid() catches
unmapped members — at runtime, in a test somebody must remember to keep honest, with ignore
lists that rot invisibly as models evolve.
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.
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;
}
| AutoMapper | Mapwright | What 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. |
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.
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.
| AutoMapper | Hand-written (interim) | Mapwright | |
|---|---|---|---|
| Mapping configuration/code to maintain | ~190-line MapperFactory | ~45 lines × 8 entity files | 3–5 declaration lines per entity |
| Unmapped property discovered | Runtime test (if written) | Runtime verifier test | IDE squiggle, build warning/error |
| Stale ignore entry discovered | Never | Never | Build error |
| Runtime machinery | Reflection engine + config cache | None | None (attributes only; AOT/trim safe) |
| Can you step through a mapping? | No | Yes | Yes — it's a .g.cs file |
Mapwright attributes package and the
Mapwright.Generator analyzer package to the project that owns the mappings.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.[AfterMap]. When the build is clean,
the mapping layer is provably total._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.dotnet_diagnostic.MW0001.severity = error in
.editorconfig once the team trusts the workflow — full AutoMapper-strictness with
zero runtime cost.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.
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.