Migrating from AutoMapper to Mapster: A Practical Guide
A practical plan and field notes for replacing AutoMapper with Mapster, including an API equivalence table, a suggested migration order, and gotchas to watch for.
Why Migrate
If you're maintaining a codebase pinned to an old AutoMapper version — whether because of framework constraints, licensing changes in newer AutoMapper releases, or plain technical debt — Mapster is a solid migration target. For codebases still relying on AutoMapper's static initialization API (Mapper.Initialize()), Mapster provides a comparable static-call experience through its Adapt<T>() extension method, without requiring a move to DI-based IMapper injection. That makes it a largely mechanical replacement rather than an architectural rewrite.
Assessing Your Codebase First
Before starting, get a clear picture of scope:
- How many projects/assemblies reference AutoMapper, and how is the version pinned (NuGet
PackageReferencevs. legacypackages.config)? - Is configuration centralized in one or a few profile classes, or scattered across many small ones?
- Are call sites using the static
Mapper.Map<T>()API, an injectedIMapper, or a mix of both? InjectedIMapperusage adds an extra step — swapping DI registrations and interfaces — on top of the call-site conversion described below. - Do you use EF's
ProjectTo<T>()for IQueryable projection anywhere? Mapster's equivalent isProjectToType<T>(), and it needs to be verified against generated SQL separately from the rest of the migration — don't treat it as a drop-in with the other call-site patterns.
If everything funnels through the static API, the migration is largely a find-and-replace exercise guided by an equivalence table, followed by careful handling of a small number of non-trivial mapping constructs.
API Equivalence Table
| AutoMapper | Mapster Equivalent |
|---|---|
Mapper.Initialize(cfg => {...}) |
Configure TypeAdapterConfig.GlobalSettings at startup |
cfg.CreateMap<S, D>() |
TypeAdapterConfig<S, D>.NewConfig() |
.ForMember(d => d.X, opt => opt.MapFrom(s => s.Y)) |
.Map(d => d.X, s => s.Y) |
.ForMember(d => d.X, opt => opt.Ignore()) |
.Ignore(d => d.X) |
.ForMember(d => d.X, opt => opt.ResolveUsing(lambda)) |
.Map(d => d.X, s => lambda(s)) inline |
.ForMember(d => d.X, opt => opt.Condition(pred)) |
.Map(...).When((s, d) => pred) |
.ConvertUsing((src, dest, ctx) => ...) |
.MapWith(src => ...), calling .Adapt<T>() internally for nested mapping |
.ReverseMap() |
.TwoWays() |
.ForAllOtherMembers(opt => opt.Ignore()) |
.IgnoreNonMapped(true) |
Mapper.Map<D>(src) |
src.Adapt<D>() |
Mapper.Map(src, existingDest) |
src.Adapt(existingDest) |
Mapper.Map<S, D>(src) |
src.Adapt<S, D>() |
Suggested Migration Order
Work from the inside out — the shared mapping configuration first, since every other part of the codebase depends on it.
- Establish a baseline. Run your existing mapping-related tests before touching any code — they become the regression suite for the rest of the migration.
- Migrate the shared configuration. Add Mapster to the project(s) that host your mapping config, recreate your
CreateMapcalls using the equivalence table below, and callTypeAdapterConfig.GlobalSettings.Compile()during startup — this fails fast on misconfigured mappings instead of surfacing errors at request time. Don't remove the AutoMapper reference from this project yet; keep both running side by side until parity is confirmed. - Convert call sites, moving outward from the configuration. A reasonable order is data-access code first (usually the largest share of call sites), then business/service logic, then whatever sits at the edges of your app (API controllers, background jobs, CLI entry points — whatever applies). Swap
using AutoMapper;forusing Mapster;and convertMapper.Map<T>(x)calls tox.Adapt<T>()as you go. - Give map-into-existing-object calls extra scrutiny.
Mapper.Map(src, dest)→src.Adapt(dest)looks like a 1:1 swap, but the two libraries handle null source values differently — see gotcha #3 below. - Update startup wiring everywhere, not just the primary entry point — every process that initializes mapping (main app, each background job, test setup) needs its initializer call swapped from AutoMapper's to Mapster's.
- Clean up. Remove the AutoMapper package from the solution entirely and grep for residual
AutoMapperreferences — the count should be zero.
Non-Trivial Mappings Worth Flagging Up Front
Not every CreateMap call converts one-to-one. A few patterns are worth identifying before the migration starts rather than discovering them partway through:
- Map-into-existing-object calls used for merge logic. Mapster's
Adapt(dest)can overwrite non-null destination values with null source values by default — different from what a merge operation typically wants. ConvertUsingwith resolution-context nested mapping. Replace the context-based nestedMap<T>()call with.Adapt<T>()inside a.MapWith()lambda.ResolveUsingwith multi-statement resolver logic (e.g., picking a preferred value from several candidates with ordering/filtering rules). These need to be refactored into inline lambdas or extracted static helper methods — Mapster's fluent config doesn't accept arbitrary multi-statement blocks the same way.ForAllOtherMembers(opt => opt.Ignore())..IgnoreNonMapped(true)is the equivalent, but verify no properties are accidentally left mapped once the flag is applied.
Gotchas Found During and After Migration
1. Case-Sensitive Property Name Matching
Mapster does not match properties across different casing by default. A source property processId will not automatically map to a destination property ProcessId — AutoMapper was more forgiving here.
class Source
{
public Guid processId { get; set; }
}
class Destination
{
public Guid ProcessId { get; set; }
}
// explicit per-mapping fix
TypeAdapterConfig<Source, Destination>.NewConfig()
.Map(dest => dest.ProcessId, src => src.processId);
// or apply once, globally, to prevent repeat regressions
TypeAdapterConfig.GlobalSettings.Default
.NameMatchingStrategy(NameMatchingStrategy.Flexible);
The global flexible-matching setting is the safer default — it covers camelCase/PascalCase mismatches everywhere instead of requiring an explicit .Map() call per property, per class.
2. Collection Mapping Must Use the Exact Registered Types
Mapster resolves configuration by exact type pair. Registering:
TypeAdapterConfig<IEnumerable<Source>, IList<Destination>>.NewConfig();
means a call site using a different concrete type won't find that configuration:
IEnumerable<Source> sources = new List<Source>();
var mapped = sources.Adapt<List<Destination>>(); // does not use the config above
The call site has to match the exact types used during configuration:
var mapped = sources.Adapt<IList<Destination>>(); // matches
The safer long-term pattern is to avoid registering collection-to-collection configs at all and rely on Mapster's built-in collection support, which adapts element types automatically regardless of the outer collection type.
3. Null Source Values in Map-Into-Existing Calls
AutoMapper's Mapper.Map(src, dest) behavior around null source properties doesn't necessarily match Mapster's default, which can overwrite existing destination values with incoming nulls. For merge-style call sites — combining two records into one, for example — verify the original AutoMapper behavior first, then configure explicitly if needed:
TypeAdapterConfig<Source, Destination>.NewConfig()
.IgnoreNullValues(true);
4. Failures During Mapping Are No Longer Silent
AutoMapper tends to be lenient about exceptions thrown mid-mapping. Mapster is not — a mapping expression that throws (for example, deserializing a JSON string that turns out to be null) fails the whole Adapt() call instead of producing a partial result.
// throws if doc.Emails or doc.Phones is null
TypeAdapterConfig<Doc, Dto>.NewConfig()
.Map(dto => dto.Emails, doc => JsonConvert.DeserializeObject<List<string>>(doc.Emails).Distinct())
.Map(dto => dto.Phones, doc => JsonConvert.DeserializeObject<List<string>>(doc.Phones).Distinct());
// guard against null before deserializing
TypeAdapterConfig<Doc, Dto>.NewConfig()
.Map(dto => dto.Emails, doc => doc.Emails != null
? JsonConvert.DeserializeObject<List<string>>(doc.Emails).Distinct()
: Enumerable.Empty<string>())
.Map(dto => dto.Phones, doc => doc.Phones != null
? JsonConvert.DeserializeObject<List<string>>(doc.Phones).Distinct()
: Enumerable.Empty<string>());
5. Compile-Time Validation Belongs in Every Entry Point
TypeAdapterConfig.GlobalSettings.Compile() validates every registered mapping at startup, catching missing or misconfigured mappings before the first request or job run. It's worth calling from every process entry point in the solution — the main web app, each background job's startup, and the test project's setup — not just the primary API.
Verification Checklist
- Solution compiles with zero errors after each stage
- All mapping unit tests pass with identical assertions (same inputs → same outputs)
TypeAdapterConfig.GlobalSettings.Compile()succeeds at startup in every entry pointgrep -r "AutoMapper" --include="*.cs"returns zero results after cleanup- Manual regression pass on any workflow with non-trivial resolver logic: merge/deduplication flows, multi-candidate selection logic, and any nested or collection-heavy mappings
Lessons From Rolling It Out
- Ship one pull request per stage (or at minimum one commit per stage within a single PR) so a regression can be isolated and rolled back without unwinding the entire migration.
- Treat the flagged non-trivial mappings (merge logic, nested resolution, multi-condition resolvers) as their own mini-projects with dedicated test coverage, rather than folding them into the mechanical find-and-replace work.
- Budget real time for a manual regression pass beyond the automated test suite — collection-type mismatches and null-handling differences tend to surface in exploratory testing rather than unit tests written against the old behavior.