Migrating from AutoMapper to Mapperly in .NET: A Step-by-Step Guide

Search for a command to run...

No comments yet. Be the first to comment.
The first RAG system I shipped answered beautifully about concepts and failed completely on part numbers. Ask it "how do I reset a stuck deployment" and it nailed the answer. Ask it "what does error P

You built the pipeline. Documents are chunked, embedded, and sitting in a vector store. Retrieval returns results. And your RAG answers still hallucinate in .NET, confidently telling users about a stu

You upgraded a working API to .NET 10, hit build, and Swashbuckle fell apart. Missing namespaces, OpenApiSchema refusing to compile, AddSecurityRequirement complaining about a delegate it never wanted

You add an entity, run dotnet ef migrations add AddOrders, and the tooling stops dead: Unable to create an object of type 'AppDbContext'. For the different patterns supported at design time, see https

Coding Droplets
303 posts
Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.
From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.
AutoMapper has been the default object mapper in .NET for over a decade, and for most of that time nobody thought about it. Version 15.0.0 changed that. It ships under a dual license now, with a free community tier limited to organizations under $5,000,000 USD in annual gross revenue that have also taken less than $10,000,000 USD in outside capital, and paid tiers starting at $799 per year for one to ten developers. MediatR made the same move at version 13.0.0. If your company clears either threshold, migrating from AutoMapper to Mapperly is suddenly a line item on someone's roadmap, and it probably landed on yours.
The good news is that this migration is more mechanical than it looks. The bad news is that the parts which are not mechanical are the parts that fail silently at runtime, and those are the ones worth planning for. I have run this migration on a codebase with roughly 140 mappings, and the compiler caught almost everything. Almost. Below is the path that worked, in the order that kept the build green. If you want the full before-and-after solution with the awkward cases already solved, the annotated source is on Patreon, including the projection and enum edge cases that cost me the most time.
Licensing is the trigger, but it is rarely the only reason once a team actually looks at the alternatives.
Licensing. AutoMapper 15.0.0 and later require a commercial license above the revenue and funding thresholds. Earlier versions keep their original open-source terms, so pinning to 14.x is a legitimate short-term hold. It is not a strategy, because you stop receiving fixes for a library that sits in the middle of every request.
Native AOT and trimming. AutoMapper resolves mappings at runtime through reflection and expression compilation. That is fundamentally at odds with trimming and Native AOT. Mapperly is a Roslyn source generator: it emits plain C# assignment code at build time, so there is nothing for the trimmer to guess about.
Startup cost and runtime overhead. AutoMapper builds and validates its configuration on first use. Mapperly's cost is paid by the compiler. At runtime you are calling a method that does dto.Name = entity.Name; and the JIT treats it accordingly.
Silent failures become build failures. This is the one that actually changed my mind. AutoMapper will happily leave a target property at its default value if it cannot find a source for it, and you find out in production when a field is null. Mapperly emits a diagnostic. That difference is worth the migration on its own.
Mapperly is Apache 2.0, currently at 4.3.1, and targets .NET Standard 2.0, so it works on everything from .NET Framework through .NET 10.
No, and going in expecting one is how migrations stall. AutoMapper is a runtime engine you configure; Mapperly is a code generator you declare against. That difference shows up in four concrete places.
Mappings are typed methods, not Map<T>(object) calls. There is no untyped runtime API. If you have code that maps object to a Type resolved at runtime, Mapperly cannot express it and you will need a switch or a small registry.
ReverseMap() has no equivalent. You declare each direction as its own partial method. More lines, but you can see both directions in the file.
Flattening is not automatic. AutoMapper's convention of matching CustomerName to Customer.Name is not implied. You state it with [MapProperty].
Assembly scanning goes away. You register mapper classes in DI explicitly, which is more typing and considerably less magic.
None of these are hard. All of them are work you have to actually do, so scope the migration by counting your CreateMap calls before you commit to a sprint.
Every AutoMapper Profile becomes one or more classes marked [Mapper], and every CreateMap becomes a partial method whose signature declares the mapping.
Before:
public class ProductProfile : Profile
{
public ProductProfile() => CreateMap<Product, ProductDto>();
}
After:
[Mapper]
public partial class ProductMapper
{
public partial ProductDto ToDto(Product product);
}
The generator fills in the body. There is no runtime configuration object, no IMapper, and no startup validation step, because the mapping either compiles or it does not.
Group related mappings into one mapper class rather than creating one class per mapping. A CatalogMapper holding products, categories, and variants keeps the file count sane and lets Mapperly reuse the nested mappings automatically. When it needs a CategoryDto while mapping a Product, it finds the method you already declared on the same class.
ForMember splits into a few different attributes depending on what it was doing.
Renames use [MapProperty] with source and target names:
[Mapper]
public partial class ProductMapper
{
[MapProperty(nameof(Product.Title), nameof(ProductDto.Name))]
public partial ProductDto ToDto(Product product);
}
Computed values point [MapProperty] at a method with Use:
[MapProperty(nameof(Product.PriceMinor), nameof(ProductDto.Price), Use = nameof(ToDisplayPrice))]
public partial ProductDto ToDto(Product product);
private static string ToDisplayPrice(int minorUnits) => (minorUnits / 100m).ToString("C");
Flattening is an explicit path. AutoMapper would have guessed this; Mapperly wants it written down:
[MapProperty("Category.Name", nameof(ProductDto.CategoryName))]
public partial ProductDto ToDto(Product product);
Deliberate omissions use [MapperIgnoreTarget] or [MapperIgnoreSource]. Use these rather than suppressing the diagnostic globally, because the attribute records the decision in the code where the next person will see it.
Value resolvers that needed services become constructor parameters. Mapperly generates a partial class, so you own the constructor:
[Mapper]
public partial class ProductMapper
{
private readonly IPricingService _pricing;
public ProductMapper(IPricingService pricing) => _pricing = pricing;
[MapProperty(nameof(Product.PriceMinor), nameof(ProductDto.Price), Use = nameof(Format))]
public partial ProductDto ToDto(Product product);
private string Format(int minorUnits) => _pricing.Format(minorUnits);
}
That pattern replaces IValueResolver and ITypeConverter cleanly, and it removes the indirection where you had to go find the resolver class to understand what a property did.
This is the step that matters most for API performance, and it is the one people miss. AutoMapper's ProjectTo<T>() builds an expression tree so EF Core selects only the columns the DTO needs. If you migrate a ProjectTo call into a .ToListAsync() followed by an in-memory map, you have just turned a narrow projection into a full entity load, and the query gets slower without a single test failing.
Mapperly generates queryable projections from a static partial method returning IQueryable<TTarget>:
[Mapper]
public static partial class ProductQueryMapper
{
public static partial IQueryable<ProductDto> ProjectToDto(this IQueryable<Product> source);
}
The call site changes shape but keeps the semantics:
// Before
var dtos = await db.Products.ProjectTo<ProductDto>(_config).ToListAsync(ct);
// After
var dtos = await db.Products.ProjectToDto().ToListAsync(ct);
Two constraints are worth knowing before you start. Queryable projections must live on a static mapper class, so they cannot use injected services. And the generated expression only supports what EF Core can translate, which means custom methods with Use are off the table here. If a projection genuinely needs a service, project to an intermediate shape and finish the mapping in memory, which is what the old code was effectively doing anyway.
Grep for ProjectTo first, before you touch anything else, and handle those call sites deliberately. Everything else in this migration degrades loudly. This one degrades quietly, in your p95.
Assembly scanning is gone. Register each mapper explicitly and pick the lifetime based on what it depends on:
builder.Services.AddSingleton<ProductMapper>(); // no dependencies, stateless
builder.Services.AddScoped<InvoiceMapper>(); // depends on a scoped service
A mapper with no constructor dependencies is stateless generated code and belongs as a singleton. A mapper that injects anything scoped, a DbContext or a per-request context accessor, must be scoped. Registering it as a singleton is the classic captive dependency bug, and it will surface as an ObjectDisposedException under concurrency rather than at startup. If you have hit that before with other services, the dependency injection lifetime mistakes that cause it are the same ones here.
Then update call sites from _mapper.Map<ProductDto>(product) to _productMapper.ToDto(product). A find-and-replace gets you most of the way; the compiler finds the rest.
This is where the migration pays for itself, and where you should spend the extra hour.
Mapperly's analyzer reports two diagnostics that map directly onto the class of bug AutoMapper let through:
RMG012 - "Source member was not found for target member". A property on your DTO that nothing fills. This is the null field in production, caught at build time.
RMG020 - "Source member is not mapped to any target member". A property on your entity that goes nowhere. Often intentional, sometimes a forgotten field on a new DTO.
Both default to Warning. In a codebase with existing warnings, a warning is invisible. Promote them:
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);RMG012</WarningsAsErrors>
</PropertyGroup>
I would promote RMG012 to an error and leave RMG020 as a warning to start. An unfilled target property is nearly always a bug. An unmapped source property is frequently deliberate, and turning it into an error on day one produces a wall of noise that teams resolve by suppressing the whole rule, which defeats the purpose.
If you want the strictness scoped per mapper rather than globally, [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] requires only target members to be mapped. That is a good default for entity-to-DTO mappers, where the entity legitimately carries fields the DTO does not want.
Six things bit me or people I have walked through this. None are blockers; all are easier to handle if you see them coming.
Enum mapping is stricter. Mapperly matches by name by default and will tell you when members do not line up. AutoMapper was looser. Where the two enums genuinely differ, declare the strategy explicitly rather than suppressing the diagnostic.
Records and init-only properties map through the constructor. Usually this just works. When a record has multiple constructors, disambiguate with [MapperConstructor].
Nullable handling is stricter. Mapping a nullable source to a non-nullable target produces a diagnostic instead of a silent default. Decide per property whether you want a fallback or want it to be an error.
Collections need a declared element mapping. Declare ProductDto ToDto(Product p) and Mapperly handles List<Product> to List<ProductDto> for you. Without the element mapping it cannot infer one.
ReverseMap() hides asymmetry. When you write both directions explicitly, you often discover the reverse mapping was never actually correct and nobody noticed because nothing validated it.
Untyped mapping has no equivalent. Code doing _mapper.Map(source, sourceType, targetType) needs restructuring into typed calls. This is the only part that can require real design work, and it is worth finding these early.
Do not attempt a big-bang swap. Both libraries coexist fine, which lets you migrate incrementally with a working build at every commit.
Add Riok.Mapperly alongside AutoMapper. Do not remove anything yet.
Inventory the work: count CreateMap calls, and separately grep for ProjectTo and any untyped Map overloads. Those two lists are your risk.
Migrate one bounded area, ideally one with good test coverage. Confirm the generated output looks like what you expect by opening it in the IDE.
Convert the ProjectTo call sites next, while you still have the AutoMapper version in git to diff generated SQL against.
Work through the remaining profiles, deleting each AutoMapper profile as its replacement lands.
Remove the AutoMapper package reference. The compiler now tells you about every straggler.
Promote RMG012 to an error and fix the fallout. This is the step that surfaces mappings that were quietly broken all along.
Step 7 is where I found three genuine bugs in mappings that had been in production for months. That is the argument for doing this migration properly rather than mechanically, whatever prompted it.
If you have not settled on Mapperly yet, the comparison of AutoMapper, Mapster, and Mapperly covers the trade-offs between them properly. And if this licensing pattern feels familiar, it is: MassTransit made a similar commercial move, and the planning questions are close to identical.
Do I have to migrate from AutoMapper if my company is small?
No. The free community tier covers organizations under $5,000,000 USD in annual gross revenue that have also received less than $10,000,000 USD in outside capital, with separate terms for government and higher-education entities. Check the current terms on the official AutoMapper site rather than trusting a blog post, including this one, since they can change. What is worth planning for is the threshold itself: if you expect to cross it, migrating while the codebase is small is far cheaper than migrating after it doubles.
Can I keep using AutoMapper 14 instead of migrating?
Yes, in the short term. Versions before 15.0.0 remain under their original open-source license. Treat it as a hold, not a decision. You stop receiving bug fixes and framework compatibility updates for a library that sits on every request path, and the eventual migration only gets larger. Pinning buys you time to plan; it does not remove the work.
How long does an AutoMapper to Mapperly migration actually take?
For a codebase with roughly 150 straightforward mappings, plan two to three days of focused work plus a review cycle. The variable is not the mapping count, it is how many ProjectTo call sites and untyped Map(object, Type) calls you have. Simple CreateMap conversions run at several per minute once you have the pattern. A single untyped mapping site can eat an afternoon of redesign. Count those two things first and your estimate will hold.
Does Mapperly work with Native AOT and trimming?
Yes, and this is one of its main advantages. Mapperly is a source generator that emits ordinary C# assignment code at compile time, so there is no runtime reflection or expression compilation for the trimmer to reason about. AutoMapper's runtime configuration model is fundamentally difficult to trim, which is why teams targeting Native AOT often end up here regardless of licensing.
What replaces AutoMapper's ProjectTo in Mapperly for EF Core?
A static partial method returning IQueryable<TTarget>, typically declared as an extension method, which Mapperly implements as an expression tree EF Core can translate into SQL. Call it as db.Products.ProjectToDto() in place of db.Products.ProjectTo<ProductDto>(config). The important constraint is that it must be static and therefore cannot use injected services, and it only supports operations EF Core can translate.
Will Mapperly catch mapping bugs AutoMapper missed?
In my experience yes, and it is the most underrated part of the migration. AutoMapper silently leaves a target property at its default when no source matches; Mapperly reports RMG012 at build time. Promoting that diagnostic to an error after the migration is what surfaces mappings that were quietly incomplete. On the codebase I migrated it found three real bugs that had shipped months earlier.
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com