Object Mapping Libraries
Key Points
- Object mapping = converting between shapes (Entity ↔ DTO, DTO ↔ ViewModel). Four real options in 2026: manual, AutoMapper, Mapster, Riok.Mapperly.
- Manual mapping is the senior default for small/medium projects and especially in Vertical Slice Architecture where mapping is feature-local. Compile-time, refactor-safe, zero magic.
- AutoMapper is the reflection-based veteran.
Profile+CreateMap. ItsProjectTo<T>()uniquely translates to SQL via expression trees — important for EF read models. Pain points: silent member drift, opaque debugging, runtime-only validation. - Mapster is the middle road. Reflection by default, optional source-gen. Faster than AutoMapper, easier to debug.
- Riok.Mapperly is the modern recommendation for new code: pure source-gen, compile-time safety, zero reflection, AOT/trim friendly. The trade-off is rigidity (no runtime config).
- Decision rule: few small DTOs → manual. New large project → Mapperly. Existing AutoMapper → don't rewrite. EF projection-heavy reads → keep AutoMapper or hand-write
Select.
Concepts (deep dive)
Why this matters
Mapping happens at every boundary: HTTP request → command, entity → DTO, gRPC message → domain. The senior judgment call isn't "which library is fastest?" — it's "do I need a library at all, and if so, which one matches this codebase's style?"
The .NET community has been moving away from AutoMapper for new code since ~2023 — partly performance, mostly because hand-rolled mapping has stopped being painful in modern C# (records, target-typed new, primary constructors, collection expressions).
The four options at a glance
| Option | Mode | Speed | Refactor-safe | EF projection | Best fit |
|---|---|---|---|---|---|
| Manual | Hand-written | Fastest | ✅ | ✅ (via Select) | Small/medium; VSA |
| AutoMapper | Reflection | Slowest | ❌ | ✅ (ProjectTo) | Legacy, big graphs, EF read models |
| Mapster (reflection) | Reflection + IL emit | Mid | Partial | Manual | Mid-size; AutoMapper migration |
| Mapster (source-gen) | Source-gen | Fast | ✅ | Manual | New code wanting flexibility |
| Riok.Mapperly | Source-gen only | ~ Manual | ✅ | Manual | New code; AOT/trim |
The same example, four ways
The setup: an Order entity → an OrderDto.
public class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public DateTime PlacedOn { get; set; }
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
}
public record OrderDto(Guid Id, Guid CustomerId, DateTime PlacedOn, decimal Total, string Status);
Manual
public static class OrderMapping
{
public static OrderDto ToDto(this Order o) =>
new(o.Id, o.CustomerId, o.PlacedOn, o.Total, o.Status.ToString());
}
// Usage
var dto = order.ToDto();
Five lines. Compiles or doesn't. Renaming Total → GrandTotal on either side surfaces a build error immediately. No tooling, no docs to read, no abstractions to learn.
AutoMapper
public class OrderProfile : Profile
{
public OrderProfile()
{
CreateMap<Order, OrderDto>()
.ForCtorParam(nameof(OrderDto.Status), o => o.MapFrom(s => s.Status.ToString()));
}
}
// Startup
builder.Services.AddAutoMapper(typeof(OrderProfile));
// Usage
public class OrderQueries(IMapper mapper, AppDb db)
{
public Task<List<OrderDto>> GetAllAsync() =>
db.Orders.ProjectTo<OrderDto>(mapper.ConfigurationProvider).ToListAsync();
}
ProjectTo translates the mapping into a SQL SELECT with only the columns the DTO needs. That's the AutoMapper superpower — partial selects through EF.
Mapster
TypeAdapterConfig<Order, OrderDto>.NewConfig()
.Map(d => d.Status, s => s.Status.ToString());
// Usage
var dto = order.Adapt<OrderDto>();
var dtos = orders.Adapt<List<OrderDto>>();
// Source-gen mode (recommended): mark a partial class
[Mapper]
public partial class OrderMapper
{
public partial OrderDto ToDto(Order order);
}
Riok.Mapperly
[Mapper]
public partial class OrderMapper
{
[MapProperty(nameof(Order.Status), nameof(OrderDto.Status))]
public partial OrderDto ToDto(Order order);
}
// Usage
var mapper = new OrderMapper();
var dto = mapper.ToDto(order);
The analyzer generates the implementation at compile time. You can open it in your IDE (Go to Definition → generated source). When Order.Total is renamed, you get a compiler diagnostic, not a runtime exception six weeks later in production.
EF projection — the AutoMapper edge
This is the one place AutoMapper still has a genuine advantage in 2026:
// AutoMapper — single SQL query, only DTO columns selected
db.Orders
.Where(o => o.CustomerId == id)
.ProjectTo<OrderDto>(mapper.ConfigurationProvider) // expression tree → SQL
.ToListAsync();
// Mapperly / Mapster — must write the projection by hand
db.Orders
.Where(o => o.CustomerId == id)
.Select(o => new OrderDto(o.Id, o.CustomerId, o.PlacedOn, o.Total, o.Status.ToString()))
.ToListAsync();
For read models with deep graphs, ProjectTo<T>() is hard to beat. Mapperly can't help here — its generated code uses materialized objects, not expression trees. The pragmatic move: manual Select projections for queries, library-mapped DTOs for command results.
Cross-link: EF Core — Querying & Projections.
Performance (BenchmarkDotNet, indicative)
| Method | Mean | Allocated |
|----------- |---------- |---------- |
| Manual | 12 ns | 80 B |
| Mapperly | 14 ns | 80 B |
| Mapster SG | 28 ns | 80 B |
| Mapster | 95 ns | 120 B |
| AutoMapper | 340 ns | 240 B |
Numbers vary by graph size. The trend is stable: Mapperly ~ Manual; Mapster middle; AutoMapper noticeably slower. For high-RPS hot paths, prefer source-gen. For warm paths and CRUD endpoints, the difference is dwarfed by your DB call.
Vertical Slice Architecture (VSA) angle
In VSA, each feature folder owns its own mapping:
Features/
Orders/
PlaceOrder/
Command.cs
Handler.cs
Validator.cs
Mapping.cs ← static methods, ~10 lines, feature-scoped
Mapping libraries become friction here. A library wants global config (Profiles, TypeAdapterConfig); VSA wants feature-local code. Manual mapping wins in VSA every time. Mapperly is acceptable (one [Mapper] partial class per feature). AutoMapper Profiles fight the architecture.
Cross-link: Vertical Slice Architecture.
Migrating from AutoMapper to Mapperly
You don't rewrite the world in a sprint. The pragmatic path:
- Keep AutoMapper running. Don't remove it.
- New mappers go to Mapperly. New
[Mapper]classes feature by feature. - When a Profile breaks (refactor, rename) — port that one to Mapperly instead of fixing the Profile.
- Read-model projections that use
ProjectTostay on AutoMapper indefinitely or convert to hand-writtenSelect. Don't force them. - After ~12 months, AutoMapper has shrunk to one or two leaf libraries — then decide whether the final removal is worth a sprint.
Null handling differences
// AutoMapper: nulls propagate; nested-null ignored unless configured
CreateMap<Customer, CustomerDto>()
.ForMember(d => d.Address, o => o.NullSubstitute(new AddressDto()));
// Mapperly: explicit null strategies
[Mapper(AllowNullPropertyAssignment = false)]
public partial class CustomerMapper { /* ... */ }
Each library has its own answer to "what if source.Address is null and dest.Address is not nullable?" Read the docs of whichever you choose. Bugs cluster here.
JSON-as-mapper
Sometimes "mapping" is just a serialize-deserialize round trip:
When it fits: quick scripts, schema-similar shapes, low frequency. When it doesn't: hot paths (hundreds of ns extra), cyclic graphs, custom converters needed, you actually need a real mapper. Don't ship this in production code as a substitute for thinking.
Code: correct vs wrong
❌ Wrong: AutoMapper Profile that silently drops members
CreateMap<Order, OrderDto>(); // works today
// Tomorrow: someone renames OrderDto.Total → OrderDto.GrandTotal
// AutoMapper now silently sets GrandTotal = 0. Tests pass. Bug ships.
✅ Correct: AutoMapper with AssertConfigurationIsValid
public void Configure(IApplicationBuilder app)
{
var mapper = app.ApplicationServices.GetRequiredService<IMapper>();
mapper.ConfigurationProvider.AssertConfigurationIsValid(); // throws on missing
}
Run this at startup or in a unit test. Drift caught immediately.
✅ Better: Mapperly (drift caught at compile time)
[Mapper]
public partial class OrderMapper
{
public partial OrderDto ToDto(Order order);
}
// Renaming OrderDto.Total → OrderDto.GrandTotal: compiler error, build fails.
❌ Wrong: AutoMapper for VSA feature-local mapping
// Adding a Profile in Features/Orders/PlaceOrder/Mapping.cs
public class PlaceOrderProfile : Profile { /* ... */ }
// Fragments global config; cross-feature dependencies form silently.
✅ Correct: feature-local static method
internal static class Mapping
{
public static Order ToEntity(this PlaceOrderCommand cmd) =>
new() { /* ... */ };
}
❌ Wrong: dropping ProjectTo and materializing the entity
db.Orders.ToListAsync()
.Result
.Select(o => mapper.Map<OrderDto>(o)); // SELECT * then narrow in memory
Pulls every column. Watch the SQL — this is the most common AutoMapper-removal regression.
✅ Correct: hand-written projection or ProjectTo
db.Orders.Select(o => new OrderDto(o.Id, o.CustomerId, o.PlacedOn, o.Total, o.Status.ToString()))
.ToListAsync();
Design patterns for this topic
Pattern 1 — "Manual + extension method"
- Intent: static
ToDto/ToEntityextensions; explicit, refactor-safe. - Use when: small/medium app; VSA; senior team.
Pattern 2 — "Mapperly per feature"
- Intent: one
[Mapper] partial classper feature; compile-time-checked. - Use when: new code, AOT/trim required, want a library without runtime cost.
Pattern 3 — "AutoMapper + ProjectTo for read models only"
- Intent: keep AutoMapper for queries that benefit from SQL-translated projection; manual elsewhere.
- Use when: EF-heavy app with deep DTO graphs.
Pattern 4 — "Mapster source-gen with TypeAdapterConfig"
- Intent: library flexibility (custom config) + source-gen performance.
- Use when: migrating from AutoMapper, need runtime tweaks.
Pattern 5 — "Hybrid (library + manual)"
- Intent: library for trivial 1:1 mappings; manual for any DTO with shaping logic.
- Use when: large codebase where neither pure approach fits.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Manual | Refactor-safe, fastest, simple | Tedious for big graphs |
| AutoMapper | Mature, ProjectTo for EF | Slow, opaque, drift risk |
| Mapster | Faster than AM, dual mode | Less mature, smaller community |
| Mapperly | Compile-safe, AOT, fast | Rigid, no runtime config |
| JSON round-trip | Trivially simple | Slow, fragile, not for hot paths |
When to use / when to avoid
- Use manual for VSA, small/medium apps, senior teams that value clarity.
- Use Mapperly for new large projects, AOT/trim builds, when compile-time safety matters.
- Use Mapster when migrating from AutoMapper or needing runtime flexibility.
- Keep AutoMapper in legacy codebases — don't rewrite proactively.
- Avoid AutoMapper for new code in 2026 unless
ProjectTois load-bearing. - Avoid all libraries for a five-property DTO — write the five-line method.
Interview Q&A
Q1. Why is hand-rolled mapping respected at senior level even though libraries exist? Refactor-safety, compile-time errors, zero magic, easy code review, no abstraction tax. With records, primary constructors, and target-typed new, modern C# makes manual mapping nearly free.
Q2. How does Mapperly compare with Mapster's source-gen mode? Both generate code at compile time. Mapperly is stricter (no runtime config, fewer footguns). Mapster source-gen still allows TypeAdapterConfig runtime overrides. Mapperly is more AOT/trim friendly. Pick Mapperly for safety; Mapster for flexibility.
Q3. When does ProjectTo<> stop being useful? When the destination type doesn't translate cleanly to SQL (custom resolvers, post-mapping logic, types EF can't shape). At that point write the Select by hand and skip the abstraction.
Q4. How do I migrate from AutoMapper to Mapperly incrementally? New mappers in Mapperly; existing Profiles stay; convert one Profile when it breaks for an unrelated reason; revisit ProjectTo last (often keep or convert to hand-written Select). Multi-quarter program, not a sprint.
Q5. What about JSON-based mapping (System.Text.Json) — when does that fit? Quick scripts, low-frequency cases, schema-similar shapes. Never on hot paths and never as a substitute for understanding what the mapping should do.
Q6. Why is AutoMapper the slowest? Reflection-based member resolution + IL emit at first map + delegate caching. Even with caching, indirection and allocation cost more than direct property writes. Source-gen libraries emit straight assignments at compile time.
Q7. What's silent member drift? Adding a property on the source or destination side and forgetting to update the mapping. AutoMapper happily ignores unmatched destination members. Result: a property is silently 0/null/empty, often surfaced only when a downstream consumer notices a missing field. AssertConfigurationIsValid catches it; not all teams remember to call it.
Q8. AutoMapper or Mapperly for a brand-new green-field service? Mapperly. Compile-time safety, AOT support, zero reflection. AutoMapper has no advantage in green-field except for the EF ProjectTo case — and you can write Select by hand.
Q9. Why is mapping per-feature good in VSA? Features stay independent; no global config to coordinate; rename in one feature doesn't ripple via shared Profile. Architecture and tooling alignment.
Q10. What's the biggest mapping anti-pattern you've seen? Round-tripping AutoMapper across HTTP, gRPC, EF, and back: every layer has a Profile, the entity has 30 properties, the DTO has 28, two never map, debugging takes a day. The senior fix: explicit hand-written mapping at each boundary, even if it's "more code."
Q11. Do mapping libraries help with deep object graphs and cycles? AutoMapper has MaxDepth. Mapster honors MaxDepth. Mapperly fails fast on cycles by default. Cycles are usually a domain-modeling smell — flatten the graph or break the cycle with an ID reference.
Q12. Cost of Adapt<T>() in Mapster reflection mode vs source-gen? Reflection mode emits IL on first call and caches. Source-gen emits at compile time. Steady-state speeds are close; cold start and AOT/trim diverge.
Gotchas / common mistakes
- ⚠️ Silent member drift in AutoMapper — destination property added, no map, value silently 0. Run
AssertConfigurationIsValidat startup. - ⚠️ Dropping
ProjectToand materializing rows — pulls every column. Always check the SQL. - ⚠️ Mapping libraries in VSA — fragments feature folders. Prefer manual or one Mapperly per feature.
- ⚠️ Null-handling assumptions across libraries — same code, different behavior. Read the null docs of whichever you pick.
- ⚠️ Cycles in object graphs — usually a modeling smell. Don't fight the library; flatten the graph.
- ⚠️ Configure-once vs runtime adaptation — AutoMapper/Mapster allow runtime tweaks; Mapperly doesn't. Choose accordingly.
- ⚠️ MediatR command returning a fully-mapped DTO — commands should return the minimum (Id). Mapping in command path is a smell.
- ⚠️ Using
JsonSerializeras a "mapper" — works for prototypes; hides bugs at scale. - ⚠️ AutoMapper licensing rumors — as of 2026, AutoMapper is still MIT, but the maintainer's stance has shifted. Watch the changelog if your org has commercial-license sensitivity.