Mapwright · Beginner's guide

AutoMapper & Mapwright, explained from zero

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.

1. What problem are we even solving?

Almost every real application has more than one shape for the "same" piece of data.

A typical .NET web API has, at minimum:

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.

2. What is AutoMapper?

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.

2.1 The building blocks

ConceptWhat it is
ProfileA 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.
IMapperThe 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.

2.2 A minimal example

// 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.

2.3 How it actually works under the hood

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.

3. Why teams move away from AutoMapper

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:

Silent drift

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.

Deferred verification

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.

Runtime cost

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.

Nothing to step through

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.

4. What is Mapwright?

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.

The one-sentence version: AutoMapper answers "how do I map this?" every time your program runs. Mapwright answers it once, when you build, and from then on your compiled program is just calling a plain method someone (the generator) already wrote.

5. Mapwright's architecture — how it actually works

Mapwright ships as two NuGet packages with very different jobs:

PackageRole
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).

5.1 What happens, in order, when you build

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:

  1. Find every [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.
  2. Plan each method. For every such method, a component called 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.
  3. Check the plan for problems. If a destination property has nowhere to come from, an ignore list names a property that no longer exists, or a collection map is missing the element map it depends on, the planner records a diagnostic — the same kind of warning/error you already see for ordinary C# mistakes, identified by an ID like MW0001. These show up directly in your IDE and fail the build if severe enough, before your code ever runs.
  4. Emit real C#. If the plan is usable, 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.
  5. Compile normally. From this point on there is nothing special about your program: it's a static method call to plain, ordinary, generated C#. No attributes are read, no reflection happens, nothing is cached — because there is nothing left to do at runtime.
Why "incremental"? Steps 1–4 only re-run for a [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.

6. The four things you can declare

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 getUse 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).

6.1 The attributes

AttributeMeaning
[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.

7. How to use Mapwright — a full walkthrough

Step 1 — Install the package

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.

Step 2 — Have your two shapes ready

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

Step 3 — Declare the mapper

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

Step 4 — Build, and read what the generator wrote

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.

Step 5 — Call it like a normal static 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.

Step 6 — Watch what happens when the models drift

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:

  1. Map it for real — add a matching property to Employee, or [MapProperty] if the name differs.
  2. Ignore it on purpose[MapIgnore(nameof(EmployeeDto.Department))], if it's genuinely not this map's job.
  3. Compute it — leave it ignored by the generator and set it in an [AfterMap] method, for anything that isn't a straight copy from the source object.

8. Reading the diagnostics

IdSeverityMeaning
MW0001WarningA destination property has nothing mapping to it. Map it, [MapIgnore] it, or set it in [AfterMap].
MW0002InfoA source property is never read by anything. [MapIgnoreSource] documents that this is intentional.
MW0003ErrorAn ignore/rename attribute names a property that doesn't exist anymore — the classic "stale config" bug, now impossible to leave in place.
MW0004ErrorTwo matched properties have types that can't be converted between.
MW0005ErrorThe method signature doesn't match any of the four recognized shapes.
MW0006WarningAn in-place copy can't set an init-only property — it can only be set at construction.
MW0007ErrorA projection would recurse forever (e.g. two types that map into each other).
MW0008ErrorA collection map's element type doesn't have its own object map declared yet.
MW0009ErrorAn [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.
MW0010ErrorThe destination has no public constructor whose parameters can all be filled from source members.
MW0011WarningA by-name enum map has a source member with no same-named destination member.
MW0012ErrorA required member is neither mapped nor passed through a constructor.
MW0013ErrorA [MapDerivedType] pair doesn't derive from the base pair, or has no map of its own.
MW0014ErrorA [BeforeMap] method is missing, has the wrong signature — or was placed on a projection, where it can never run.
MW0015ErrorA generic mapping method isn't one of the recognized dispatch shapes.
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.

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

9. AutoMapper → Mapwright cheat sheet

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))[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 DINothing — call the static method directly
AssertConfigurationIsValid() testThe compiler, on every build
Want the deeper dive? docs/replacing-automapper.html walks through a real production migration of an eight-entity AutoMapper configuration to Mapwright, including the exact before/after code and the numbers that came out of it. If you are weighing Mapwright against the other option in this space, the Mapwright vs Mapperly vs AutoMapper comparison is the honest version, including where Mapwright loses.
Mapwright · MIT licensed · github.com/lodestar-labs/Mapwright