Skip to content

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. Its ProjectTo<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 TotalGrandTotal 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:

  1. Keep AutoMapper running. Don't remove it.
  2. New mappers go to Mapperly. New [Mapper] classes feature by feature.
  3. When a Profile breaks (refactor, rename) — port that one to Mapperly instead of fixing the Profile.
  4. Read-model projections that use ProjectTo stay on AutoMapper indefinitely or convert to hand-written Select. Don't force them.
  5. 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:

var json = JsonSerializer.Serialize(source);
var dest = JsonSerializer.Deserialize<TDest>(json);

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/ToEntity extensions; explicit, refactor-safe.
  • Use when: small/medium app; VSA; senior team.

Pattern 2 — "Mapperly per feature"

  • Intent: one [Mapper] partial class per 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 ProjectTo is 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 AssertConfigurationIsValid at startup.
  • ⚠️ Dropping ProjectTo and 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 JsonSerializer as 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.

Further reading