What AutoMapper is and why teams use it, then how Mapwright does the same job at compile time instead of at runtime — with a full worked example. Written for a developer who has never used either.
Almost every real application has more than one shape for the "same" piece of data.
A typical .NET web API has, at minimum:
CreatedBy);Converting between them — "mapping" — is simple in principle:
var dto = new EmployeeDto
{
Id = employee.Id,
FullName = employee.FirstName + " " + employee.LastName,
Department = employee.Department.Name,
};
It's simple once. The problem is that a real application has this same shape of code for dozens of entities, written by different people, on different days — and every one of those blocks rots the same way: someone adds a property to the entity and forgets to add it to the mapping code, or renames a property and the compiler doesn't warn about the assignment that now silently does nothing.
Both AutoMapper and Mapwright exist to solve exactly this: stop writing (and maintaining) that assignment code by hand, and get some kind of safety net for when the two shapes drift apart. They just solve it in opposite ways — one at runtime, one at compile time — and that difference is the whole story of this document.
AutoMapper is a long-standing, extremely popular .NET library
that maps objects for you. Instead of writing the assignment block above, you configure the
mapping once and then call a generic Map method everywhere you need it.
| Concept | What it is |
|---|---|
Profile | A class where you declare all the mappings for one area of the
app. You inherit AutoMapper.Profile and call CreateMap in its
constructor. |
CreateMap<TSource, TDestination>() | "Employee objects can become EmployeeDto objects." By default, AutoMapper matches properties with the same name automatically (this is called convention-based mapping). |
ForMember(...) | Overrides the convention for one property: rename it, compute it from something else, or ignore it entirely. |
IMapper | The interface you inject via dependency injection into your
controllers/services once AutoMapper is registered. Calling _mapper.Map<EmployeeDto>(employee)
performs the actual conversion, using reflection, at runtime. |
ProjectTo<T>() | An Entity-Framework-aware variant that turns the mapping into part of the SQL query itself, instead of loading full entities and mapping them in memory afterwards. |
AssertConfigurationIsValid() | A method you call — usually from a unit
test — that checks every CreateMap actually has somewhere for every destination
property to come from. |
// The two shapes
public class Employee // EF entity
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public bool? IsActive { get; set; } // nullable database column
}
public class EmployeeDto // what the API returns
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public bool IsActive { get; set; } // DTOs don't do null bools
}
// The configuration — usually one file per area of the app
public class EmployeeProfile : Profile
{
public EmployeeProfile()
{
CreateMap<Employee, EmployeeDto>()
.ForMember(d => d.IsActive, o => o.MapFrom(s => s.IsActive ?? false));
}
}
// Program.cs — register it once
builder.Services.AddAutoMapper(typeof(EmployeeProfile));
// Anywhere with IMapper injected
public class EmployeeService
{
private readonly IMapper _mapper;
public EmployeeService(IMapper mapper) => _mapper = mapper;
public EmployeeDto GetDto(Employee employee)
=> _mapper.Map<EmployeeDto>(employee);
}
That's the whole appeal: write the CreateMap once, and every Id/FirstName/LastName
property is copied over automatically by name — no hand-written assignment block, ever, for the boring
90% of properties that just match.
This part matters for understanding why Mapwright exists. When your app starts, AutoMapper
builds a MapperConfiguration by inspecting your Profile classes with
reflection (code that examines types and their properties while the program
is running, rather than at compile time). It compiles an internal execution plan for each
CreateMap, caches it, and every later Map() call runs that cached plan. None
of this happens at compile time — the C# compiler has no idea what CreateMap<Employee, EmployeeDto>()
is going to do; it only finds out when the application actually runs.
AutoMapper is not "bad" — it has mapped a huge amount of production .NET code successfully for over a decade. The reasons teams replace it are specific and worth naming honestly, because they are exactly what Mapwright's design responds to:
Rename a property on either side, and the convention match just stops
— quietly. The destination property ships as null/default. Nothing turns
red. Nobody finds out until a bug report.
AssertConfigurationIsValid() only catches problems if a
test calls it, and that test only runs when CI runs. It is opt-in safety, not built-in safety.
Reflection and cached execution plans are fast, but not free — and they are fundamentally incompatible with Native AOT/trimming (compiling .NET apps ahead-of-time into a single native binary), which more teams need for containers and cold-start latency.
When a mapping misbehaves, there is no method body to put a breakpoint in — the actual copying logic lives inside AutoMapper's engine, not in your codebase.
All four of these are runtime problems. The fix, conceptually, is to do the same configuration-driven mapping — but resolve it entirely while the code is compiling, so a bad mapping is a build error instead of a production incident. That's what a source generator gives you, and it's what Mapwright is built on.
Mapwright is a compile-time replacement for AutoMapper. You still declare what should be mapped to what — you don't hand-write the property assignments — but instead of a runtime engine reading that declaration and mapping objects on the fly, a piece of the C# compiler toolchain called a Roslyn incremental source generator reads your declaration while your project builds and writes the actual mapping method for you, as ordinary C# source code, before your program ever runs.
Mapwright ships as two NuGet packages with very different jobs:
| Package | Role |
|---|---|
Mapwright |
Just the attributes: [Mapper], [MapIgnore],
[MapIgnoreSource], [MapProperty], [AfterMap]. This is the
vocabulary you write your declarations in. Nothing in this package executes at runtime —
it compiles down to metadata only, so it costs nothing when your program runs and is safe to use
with Native AOT/trimming. |
Mapwright.Generator |
The actual engine — but it only runs during your build, inside the compiler process,
never inside your shipped application (it comes in as a dependency of Mapwright
and ships no lib/, so nothing from it lands in your output). |
Say you write this in your project (nothing here executes anything — it's a declaration, like an interface):
[Mapper]
public static partial class EmployeeMapper
{
public static partial EmployeeDto ToDto(Employee source);
}
Notice the method has no body — just a signature ending in ;. That's a
C# partial method: half the method lives in the file you wrote, and Mapwright
is going to write the other half for you as a separate file that the compiler stitches back together
into the same class. Here is what produces that other half, step by step:
[Mapper] class. The generator's entry point
(MapwrightGenerator) asks the compiler for every class decorated with
[Mapper], and for each one, every static partial method inside it that
doesn't have a body yet.MapPlanner looks at the real source and destination types via the compiler's
semantic model (the same information the IDE uses for autocomplete) and works out, property by
property: does a same-named property exist on both sides (case-insensitively)? Is one of them
inherited from a base class? Does a bool? need to collapse to a bool?
Is this property covered by [MapIgnore], renamed by [MapProperty], or
does it point at a nested object/collection that needs its own sibling map? The result is an
in-memory model of exactly what the generated code should look like — nothing is written yet.MW0001. These show up directly in your IDE and fail the build if severe enough,
before your code ever runs.MapwrightGenerator.Emit writes
out an ordinary .cs file — object initializers, null checks, GetValueOrDefault()
calls, nothing exotic — containing the missing half of your partial method. This file is added to
your compilation automatically and is visible in the IDE (in Visual Studio: your project →
Dependencies → Analyzers → Mapwright.Generator). By default it lives only inside the
compilation; if you also want it written to disk under obj/generated/, add
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> and
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)generated</CompilerGeneratedFilesOutputPath>
to your .csproj (Mapwright's own test project does exactly this). Either way, you never
have to open it for the mapper to work.[Mapper] class whose
declaration or referenced model types actually changed since the last build. In a large solution with
many mappers, editing one file doesn't force every mapper to regenerate — which is what keeps build
times sane as the codebase grows.
Every Mapwright declaration is one of four method shapes. You never write the body — the shape of the signature tells the generator which one you mean:
| You write (no body) | You get | Use it for |
|---|---|---|
static partial TDest Name(TSource s); |
A brand-new object, built with an object initializer. | Entity → DTO, DTO → domain model, etc. |
static partial void Name(TSource s, TDest t); |
An in-place copy onto an existing instance. | Updating an EF-tracked entity from an incoming DTO without replacing the tracked instance. |
static partial Expression<Func<TSource,TDest>> Name(); |
A LINQ expression tree, not a compiled method. | Passing to EF Core's .Select(...) so the mapping becomes part of the SQL query —
the direct replacement for AutoMapper's ProjectTo. |
static partial List<TDest> Name(IEnumerable<TSource> s);(a TDest[] array return works too) |
A Select over the matching object map, materialized into the list or array. |
Mapping a collection — requires the single-item object map to already exist in the same class (unless source and destination element types are identical, where no element map is needed). |
| Attribute | Meaning |
|---|---|
[Mapper] | Marks the class as one the generator should look at. |
[MapIgnore(nameof(D.Prop), ...)] | These destination properties are set by hand somewhere else (or not at all) — don't warn about them. Naming a property that no longer exists is a build error, so an outdated ignore list can never rot silently. |
[MapIgnoreSource(nameof(S.Prop), ...)] | Documents that a source property is deliberately never read by this map. |
[MapProperty("SourceName", "DestName")] | The property names genuinely differ — a real rename, not a typo. |
[AfterMap(nameof(Method))] | After the generated assignments run, call this
ordinary hand-written method — a static void Method(TSource source, TDest result) in
the same mapper class — to fill in anything a generator shouldn't guess (computed values,
timestamps, values that come from somewhere other than the source object). Not allowed on
projections: an expression tree EF translates to SQL can't call your method. |
dotnet add package Mapwright
That is the whole install. Mapwright holds the attributes and depends on
Mapwright.Generator, the build-time analyzer that writes the code, so the one reference
brings both. The generator ships no lib/; nothing from it is loaded at runtime.
public class Employee
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public bool? IsActive { get; set; }
public string Notes { get; set; } // internal-only, never leaves the API
}
public class EmployeeDto
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public bool IsActive { get; set; }
}
[Mapper]
public static partial class EmployeeMapper
{
// Notes has nowhere to go on EmployeeDto on purpose — say so explicitly.
[MapIgnoreSource(nameof(Employee.Notes))]
public static partial EmployeeDto ToDto(Employee source);
public static partial List<EmployeeDto> ToDtos(IEnumerable<Employee> source);
}
That's the entire file. No constructor, no registration, nothing to inject.
Build the project. Mapwright generates a file named
YourNamespace.EmployeeMapper.Mapwright.g.cs containing this (the real file fully
qualifies every type as global::System.ArgumentNullException,
global::YourNamespace.EmployeeDto etc. — shortened here for readability):
// <auto-generated by Mapwright — the mapping code below is yours to read, debug, and step through />
public static partial class EmployeeMapper
{
public static partial EmployeeDto ToDto(Employee source)
{
ArgumentNullException.ThrowIfNull(source);
var result = new EmployeeDto
{
Id = source.Id,
FirstName = source.FirstName,
LastName = source.LastName,
IsActive = source.IsActive.GetValueOrDefault(), // bool? → bool, visibly
};
return result;
}
public static partial List<EmployeeDto> ToDtos(IEnumerable<Employee> source)
{
ArgumentNullException.ThrowIfNull(source);
return Enumerable.ToList(Enumerable.Select(source, ToDto));
}
}
This is exactly the code you would have written by hand — which is the point. You can put a
breakpoint on the var result = new EmployeeDto line and step through it like any other
method, because it is any other method.
public class EmployeeService
{
public EmployeeDto GetDto(Employee employee)
=> EmployeeMapper.ToDto(employee);
public List<EmployeeDto> GetDtos(IEnumerable<Employee> employees)
=> EmployeeMapper.ToDtos(employees);
}
No IMapper, no DI registration, no interface to mock in tests — it's a plain static
method, so tests call it directly.
Now add a new property to EmployeeDto, say string Department, and rebuild.
The IDE underlines ToDto with:
MW0001: Destination property 'Department' on 'EmployeeDto' is not mapped by 'ToDto'.
Map it, add it to [MapIgnore], or set it in an [AfterMap] method.This is the entire point of the exercise: the thing that used to fail silently, or fail only if a test remembered to check, is now a build-time warning the moment it happens. You have three honest ways to resolve it:
Employee, or
[MapProperty] if the name differs.[MapIgnore(nameof(EmployeeDto.Department))],
if it's genuinely not this map's job.[AfterMap] method, for anything that isn't a straight copy from the source object.| Id | Severity | Meaning |
|---|---|---|
MW0001 | Warning | A destination property has nothing mapping to it. Map it, [MapIgnore] it, or set it in [AfterMap]. |
MW0002 | Info | A source property is never read by anything. [MapIgnoreSource] documents that this is intentional. |
MW0003 | Error | An ignore/rename attribute names a property that doesn't exist anymore — the classic "stale config" bug, now impossible to leave in place. |
MW0004 | Error | Two matched properties have types that can't be converted between. |
MW0005 | Error | The method signature doesn't match any of the four recognized shapes. |
MW0006 | Warning | An in-place copy can't set an init-only property — it can only be set at construction. |
MW0007 | Error | A projection would recurse forever (e.g. two types that map into each other). |
MW0008 | Error | A collection map's element type doesn't have its own object map declared yet. |
MW0009 | Error | An [AfterMap] method is missing, isn't a static void (TSource, TDest) in the mapper class — or was placed on a projection, where it can never run. |
MW0010 | Error | The destination has no public constructor whose parameters can all be filled from source members. |
MW0011 | Warning | A by-name enum map has a source member with no same-named destination member. |
MW0012 | Error | A required member is neither mapped nor passed through a constructor. |
MW0013 | Error | A [MapDerivedType] pair doesn't derive from the base pair, or has no map of its own. |
MW0014 | Error | A [BeforeMap] method is missing, has the wrong signature — or was placed on a projection, where it can never run. |
MW0015 | Error | A generic mapping method isn't one of the recognized dispatch shapes. |
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. |
Once your team trusts the workflow, promote the main one to a hard error in
.editorconfig so an unmapped property can never merge at all:
dotnet_diagnostic.MW0001.severity = error
| 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)) | [MapProperty("Q", "P")], or [AfterMap] for anything computed |
Map(source, existingTarget) (update) | static partial void Copy(S source, D target); |
ProjectTo<D>(queryable) | static partial Expression<Func<S,D>> Projection(); + .Select(...) |
IMapper injected via DI | Nothing — call the static method directly |
AssertConfigurationIsValid() test | The compiler, on every build |