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.
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:
Open a class deriving from AutoMapper.Profile. The analyzer reports
MWM001 on the class declaration:
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:
<auto-generated/> marker, because this is not generator output that gets rewritten
on the next build. You check it in, you edit it, you own it. (The mapping bodies are still
written by the Mapwright source generator at build time, as always — this file holds only the
declarations.)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 Profile | In 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.
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.
MapFrom with an arbitrary lambda — anything beyond a plain member access
or a dotted member path: o.MapFrom(s => s.First + " " + s.Last), conditionals, method
calls, null coalescing. These cannot be mechanically translated, and the reason is
structural rather than a gap in the tool: AutoMapper evaluates those lambdas as expression trees at
runtime, while Mapwright's attributes describe declarative property correspondence. There is no
attribute that means "concatenate two strings with a space between them", and inventing one would
mean rebuilding the runtime engine Mapwright exists to remove. The lambda is quoted verbatim in the
TODO so you can paste it into an [AfterMap] hook or a small hand-written method — which
is the right home for it anyway, because there it is ordinary C# you can read, test, and
breakpoint..ReverseMap() — a reverse method is generated, and member
configuration written after the .ReverseMap() call is attributed to it, because
that is the map it configures in AutoMapper. The forward ForMember configuration
written before it, however, does not carry over, so a TODO notes that the reverse direction may need
attributes of its own. AutoMapper's reverse-map semantics are genuinely subtle about which
configuration inverts and which does not, and inferring them silently would be exactly the kind of
invisible behaviour Mapwright exists to eliminate..IncludeAllDerived() with no explicit pairs — the TODO lists the derived
pairs found in your compilation so you can confirm them. "All derived types" is resolved by
AutoMapper at runtime, and the closed set is not always knowable at compile time: another assembly
can add a derived type later..ConvertUsing(...), .ForCtorParam(...), .ForAllMembers(...),
.ForAllOtherMembers(...), and anything else unrecognised — quoted verbatim as a
TODO rather than skipped. ConvertUsing in particular is a whole hand-written conversion,
and in Mapwright that is simply a normal method you write and point the mapper at.BeforeMap or AfterMap on the same map — Mapwright
allows one hook of each kind per mapping, so the first converts to an attribute and any further one
becomes a TODO. Merging two hooks into one is usually trivial, but it is a decision about ordering
and it is yours to make.ForMember naming a property that does not exist on the destination —
written as a string literal rather than a nameof, with a TODO saying so. Mapwright will
report MW0003 on it until it is corrected, which is the point: the old
Profile was carrying a stale entry that nothing was checking, and now the build checks it.ForMember that reads or writes a public field — Mapwright maps
properties, and an attribute naming a field would fail the build later rather than loudly now, so
the member becomes a TODO suggesting a wrapper property or an [AfterMap] hook.CreateMap(typeof(Open<>), ...) or a generic Profile's
CreateMap<Result<T>, ...> has no single-method equivalent in a static mapper
class, so it is quoted as a TODO; and the same source/destination pair declared twice gets each
declaration converted to its own method with a TODO asking for a by-hand merge.CreateMap calls that are not plain statements in a Profile constructor —
in a helper method, or buried in an if or a loop. These are flagged rather than missed
silently, so a Profile that configures itself indirectly does not quietly convert to half a mapper.
A base Profile's constructor is different: its CreateMap calls run whenever the derived
Profile is constructed, so they are converted along with the derived Profile — and a base declared
outside the compilation is flagged instead.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.
A realistic Profile: two maps, a flattened nested member, a computed total, an ignore, and a reverse.
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();
}
}
// 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.
Once the generated mapper compiles clean — no TODOs left, no MW0001 warnings — the remaining steps are all deletions:
_mapper.Map<OrderDto>(order) becomes
OrderProfileMapper.OrderToOrderDto(order), and the IMapper constructor
parameters go with it.services.AddAutoMapper(...)
registration, and any AssertConfigurationIsValid() test. The compiler owns that job
now.dotnet remove package Mapwright.Migration. It has done what it was for, and nothing it
produced depends on it still being there.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.