Mapwright · Migration tooling

Migrating with the analyzer

Mapwright.Migration is a build-time-only analyzer and code fix that converts an AutoMapper Profile into an equivalent Mapwright mapper — mechanically, into a new file, without touching a line of your existing code, and without pretending it can translate the parts that need a human.

1. What the package is

A Roslyn analyzer plus a code fix, shipped as a development dependency you install for the length of the migration and uninstall when it is done.

dotnet add package Mapwright.Migration

It is marked DevelopmentDependency: it contributes no runtime code, adds no assembly to your output, and does not flow transitively to packages that reference yours. It exists to run inside the compiler and inside your IDE, and then to be removed.

It also takes no dependency on AutoMapper itself. The analyzer recognises AutoMapper constructs by their fully-qualified symbol names in your compilation — AutoMapper.Profile, CreateMap, ForMember and the rest — rather than by linking against the library. Two consequences follow, and both matter:

2. How you use it

Open a class deriving from AutoMapper.Profile. The analyzer reports MWM001 on the class declaration:

MWM001 · Info   This AutoMapper Profile can be converted to a Mapwright mapper.

Info, deliberately — not a warning. An existing Profile is not a defect; nothing about it is broken and nothing needs fixing today. It is an opportunity, and a diagnostic severity that implied otherwise would be dishonest about a codebase that is working fine.

Trigger the lightbulb (Ctrl+. in Visual Studio, the quick-fix key in Rider or VS Code) and pick "Convert to a Mapwright mapper". The fix adds a new file beside the Profile:

Mapping/OrderProfile.cs              // yours, untouched
Mapping/OrderProfile.Mapwright.cs    // new — the converted mapper

Three properties of that new file are worth stating explicitly, because they are what make the tool safe to run on a real codebase:

3. What converts automatically

The mechanical majority of a Profile — the CreateMap calls and the straightforward ForMember configuration around them — has an exact Mapwright equivalent, and the fix writes it:

In the ProfileIn the generated mapper
CreateMap<TSource, TDest>() public static partial TDest Map(TSource source); — named <TSource>To<TDest> when a Profile holds several maps, so overloads cannot collide
.ForMember(d => d.X, o => o.Ignore()) [MapIgnore(nameof(TDest.X))] — repeated ignores merged into a single attribute
.ForMember(d => d.X, o => o.MapFrom(s => s.Y)) [MapProperty(nameof(TSource.Y), nameof(TDest.X))]
.ForMember(d => d.X, o => o.MapFrom(s => s.A.B)) [MapProperty("A.B", nameof(TDest.X))] — a dotted source path, which is how Mapwright flattens
.ReverseMap() A second partial method in the opposite direction. ForMember/ForPath/ForSourceMember configuration written after .ReverseMap() configures the reverse map in AutoMapper, so it is attributed to the reverse method
.ForPath(d => d.A.B, o => o.MapFrom(s => s.Y)) [MapProperty(nameof(TSource.Y), "A.B")] — a dotted target path, which is how Mapwright unflattens
.ForSourceMember(s => s.X, o => o.DoNotValidate()) [MapIgnoreSource(nameof(TSource.X))]
var m = CreateMap<...>(); m.ForMember(...); The split local-variable style converts exactly like the fluent chain, string-based ForMember("X", ...) overloads included
.Include<TDerivedSource, TDerivedDest>() [MapDerivedType(typeof(TDerivedSource), typeof(TDerivedDest))]
.BeforeMap(...) / .AfterMap(...) [BeforeMap(nameof(Hook))] / [AfterMap(nameof(Hook))], plus a generated private static void hook stub for you to fill in

Note the naming rule. A Profile with a single map gets a plain Map; a Profile with several gets OrderToOrderDto, CustomerToCustomerDto and so on. That is not cosmetic — several CreateMap calls in one Profile easily produce methods whose signatures differ only by return type, which C# will not accept as overloads.

4. What needs a human, and why

This is the important section, and the honest one. The governing principle is simple: the converter never silently drops anything.

Every construct it cannot translate is emitted into the generated file as a // TODO(mapwright): ... comment, sitting exactly where the developer will see it — directly above the method it belongs to — and counted in a summary header comment at the top of the file:

// OrderProfileMapper was converted from the AutoMapper Profile 'OrderProfile' by Mapwright.Migration.
// 2 CreateMap calls converted into 3 mapping methods.
// 2 things still need a human — search this file for TODO(mapwright).

The two counts are deliberately different numbers: the header reports CreateMap calls separately from mapping methods, because a .ReverseMap() turns one call into two methods. Reading them together tells you whether the shape of the file matches the shape of the Profile it came from.

The under-promising is deliberate. Converting 70% of a Profile and flagging the rest loudly is a better outcome than appearing to convert 100% while quietly changing what three of the mappings do. A migration tool that guesses is a migration tool you have to audit line by line — which is most of the work it claimed to save.

Things that become TODOs

The honest 70% is the point. The analyzer removes the mechanical tedium — hundreds of CreateMap and ForMember lines transcribed by hand, which is exactly where transcription errors come from — and leaves the genuinely bespoke logic visible, quoted, and counted, so a human decides where it belongs. That decision was never automatable; the transcription always was.

5. A worked before/after

A realistic Profile: two maps, a flattened nested member, a computed total, an ignore, and a reverse.

Before — OrderProfile.cs
public class OrderProfile : Profile
{
    public OrderProfile()
    {
        CreateMap<Order, OrderDto>()
            .ForMember(d => d.CustomerName,  o => o.MapFrom(s => s.Customer.Name))
            .ForMember(d => d.Total,         o => o.MapFrom(s => s.Lines.Sum(l => l.Price)))
            .ForMember(d => d.InternalNotes, o => o.Ignore());

        CreateMap<Customer, CustomerDto>().ReverseMap();
    }
}
After — OrderProfile.Mapwright.cs (new file, yours to edit)
// OrderProfileMapper was converted from the AutoMapper Profile 'OrderProfile' by Mapwright.Migration.
// 2 CreateMap calls converted into 3 mapping methods.
// 2 things still need a human — search this file for TODO(mapwright).
//
// This is ordinary source code and it belongs to you: edit it, rename it, check it in.
// It is deliberately not marked auto-generated, because nothing will rewrite it. Delete
// the original Profile once you are satisfied this says the same thing.
#nullable enable

using Mapwright;

namespace YourApp.Mapping;

[Mapper]
public static partial class OrderProfileMapper
{
    // TODO(mapwright): ForMember(d => d.Total, o => o.MapFrom(s => s.Lines.Sum(l => l.Price)))
    //     — MapFrom here is an expression, not a property path, and an expression has no declarative
    //       equivalent; move it into an [AfterMap] hook or a hand-written method
    [MapProperty("Customer.Name", nameof(OrderDto.CustomerName))]
    [MapIgnore(nameof(OrderDto.InternalNotes))]
    public static partial OrderDto OrderToOrderDto(Order source);

    public static partial CustomerDto CustomerToCustomerDto(Customer source);

    // TODO(mapwright): ReverseMap()
    //     — the reverse map below was generated, but AutoMapper's forward ForMember configuration
    //       does not carry over to it; check whether it needs its own [MapProperty]/[MapIgnore]
    public static partial Customer CustomerDtoToCustomer(CustomerDto source);
}

One simplification above, for readability: the real output fully qualifies every type reference — public static partial global::Shop.OrderDto OrderToOrderDto(global::Shop.Order source), nameof(global::Shop.OrderDto.CustomerName) — so the file compiles wherever it lands, whatever is or is not in scope around it. Shortening those to the plain names is a one-keystroke IDE cleanup once you have read the file.

Read the result against the original. Three of the four ForMember decisions came across exactly: the dotted "Customer.Name" path is how Mapwright flattens, and the ignore is now checked against the real OrderDto type on every build. The fourth — a Sum over a collection — is quoted where you cannot miss it, above the method it affects.

Resolving that TODO is a two-line edit, and what you end up with is better than what it replaced:

[MapProperty("Customer.Name", nameof(OrderDto.CustomerName))]
[MapIgnore(nameof(OrderDto.InternalNotes))]
[AfterMap(nameof(SetTotal))]
public static partial OrderDto OrderToOrderDto(Order source);

private static void SetTotal(Order source, OrderDto target)
    => target.Total = source.Lines.Sum(l => l.Price);

The total is now a named method with a breakpoint you can set, rather than an expression tree compiled by an engine at startup.

Build after converting, and let MW0001 finish the job. Every destination property the old Profile handled implicitly by convention shows up as a warning with a name attached, so the distance between "the fix converted it" and "the mapping is complete" is measured by the compiler rather than by reading.

6. Finishing the migration

Once the generated mapper compiles clean — no TODOs left, no MW0001 warnings — the remaining steps are all deletions:

  1. Move the call sites. _mapper.Map<OrderDto>(order) becomes OrderProfileMapper.OrderToOrderDto(order), and the IMapper constructor parameters go with it.
  2. Delete the Profile. Yours to delete, whenever you are ready — the tool never did it for you.
  3. Drop the AutoMapper package reference, the services.AddAutoMapper(...) registration, and any AssertConfigurationIsValid() test. The compiler owns that job now.
  4. Uninstall the migration package. dotnet remove package Mapwright.Migration. It has done what it was for, and nothing it produced depends on it still being there.
Keep going: the production case study walks a real eight-entity AutoMapper profile through the migration end to end, including the call-site and verification-test work this page hands off; the side-by-side reference is the statement-by-statement cheat sheet for anything the analyzer leaves as a TODO; and the beginner's guide explains how source-generated mapping works from zero.
Mapwright · MIT licensed · github.com/lodestar-labs/Mapwright
Mapwright.Migration is a development dependency: it ships no runtime code, takes no dependency on AutoMapper, and is meant to be uninstalled once the migration is done.