.NET & C# Development · Lesson 50 of 92

AutoMapper vs Mapster vs Manual — Which Is Worth It?

Why You Need Mapping

Your domain model is shaped for persistence and business rules. Your API response is shaped for the consumer. They should never be the same class — leaking your User entity with its password hash and navigation properties is a security and coupling problem.

Mapping sits at the boundary between those two worlds.

C#
// Domain entity — owned by your DB layer
public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public List<OrderItem> Items { get; set; } = new();
    public string CustomerId { get; set; } = string.Empty;
}

// DTO — owned by your API contract
public record OrderDto(int Id, DateTime CreatedAt, int ItemCount, decimal Total);

AutoMapper

Install:

Bash
dotnet add package AutoMapper
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

Define a profile:

C#
public class OrderProfile : Profile
{
    public OrderProfile()
    {
        CreateMap<Order, OrderDto>()
            .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count))
            .ForMember(dest => dest.Total,     opt => opt.MapFrom(src => src.Items.Sum(i => i.Price * i.Qty)));
    }
}

Register and inject:

C#
// Program.cs
builder.Services.AddAutoMapper(typeof(OrderProfile).Assembly);

// Controller/service
public class OrderService(IMapper mapper, AppDbContext db)
{
    public async Task<OrderDto?> GetAsync(int id)
    {
        var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id);
        return order is null ? null : mapper.Map<OrderDto>(order);
    }
}

Nested mapping — AutoMapper resolves registered maps automatically:

C#
CreateMap<Order, OrderSummaryDto>();
CreateMap<OrderItem, OrderItemDto>();
// AutoMapper maps Items -> ItemDtos automatically if the property names match

The cost: AutoMapper uses reflection and expression compilation. It pays that cost at startup (map validation), not at call time. Still, it's the slowest of the three at runtime.

Mapster

Install:

Bash
dotnet add package Mapster
dotnet add package Mapster.DependencyInjection

Configure:

C#
// Program.cs
builder.Services.AddMapster();

// Config — define this in a static class or IRegister implementation
TypeAdapterConfig<Order, OrderDto>
    .NewConfig()
    .Map(dest => dest.ItemCount, src => src.Items.Count)
    .Map(dest => dest.Total,     src => src.Items.Sum(i => i.Price * i.Qty));

Using IRegister keeps configs organised:

C#
public class OrderMappingConfig : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<Order, OrderDto>()
            .Map(dest => dest.ItemCount, src => src.Items.Count)
            .Map(dest => dest.Total,     src => src.Items.Sum(i => i.Price * i.Qty));
    }
}

Inject and use IMapper (Mapster's version):

C#
public class OrderService(IMapper mapper, AppDbContext db)
{
    public async Task<OrderDto?> GetAsync(int id)
    {
        var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id);
        return order is null ? null : mapper.Map<OrderDto>(order);
    }
}

Mapster also supports code generation (MapsterGen) which emits plain C# mapping code at build time — effectively zero-overhead at runtime.

Manual Mapping

No packages. No reflection. Just code.

C#
public static class OrderMappings
{
    public static OrderDto ToDto(this Order order) => new(
        order.Id,
        order.CreatedAt,
        order.Items.Count,
        order.Items.Sum(i => i.Price * i.Qty)
    );

    public static IEnumerable<OrderDto> ToDtos(this IEnumerable<Order> orders) =>
        orders.Select(o => o.ToDto());
}

Usage:

C#
var dto = order.ToDto();
var dtos = orders.ToDtos();

For reverse mapping (DTO -> domain), make it explicit:

C#
public static Order ToDomain(this CreateOrderRequest request) => new()
{
    CustomerId = request.CustomerId,
    CreatedAt  = DateTime.UtcNow,
    Items      = request.Items.Select(i => new OrderItem
    {
        ProductId = i.ProductId,
        Qty       = i.Quantity,
        Price     = i.UnitPrice
    }).ToList()
};

Performance Comparison

Rough numbers from BenchmarkDotNet mapping 10,000 Order objects (single level, 5 properties):

| Method | Mean | Allocated | |---------------|------------|-----------| | Manual | 1.8 ms | 3.1 MB | | Mapster | 2.1 ms | 3.2 MB | | Mapster (gen) | 1.9 ms | 3.1 MB | | AutoMapper | 6.4 ms | 5.8 MB |

AutoMapper is ~3x slower due to reflection paths for non-trivial maps. For most CRUD APIs this is not a bottleneck. For high-throughput batch endpoints, it matters.

When to Use Each

AutoMapper — Large team with many models and consistent naming conventions. Convention-based mapping removes boilerplate across dozens of DTOs. The AssertConfigurationIsValid() call at startup catches missing maps before they hit production.

Mapster — Performance-sensitive paths (batch processing, streaming endpoints). The codegen mode produces manual-equivalent code. Smaller API surface than AutoMapper.

Manual — Small project with few DTOs, or when mapping has complex conditional logic that a framework just makes harder. Every senior dev can read it. Zero magic, zero debugging surprise.

Flat vs Nested Mapping

Flat (AutoMapper and Mapster handle this by convention):

C#
// Source
public class Customer { public Address Address { get; set; } }
public class Address  { public string City { get; set; } }

// DTO — flattened
public record CustomerDto(string AddressCity);
// AutoMapper/Mapster resolve AddressCity -> Address.City automatically

Nested (explicit in Mapster):

C#
TypeAdapterConfig<Customer, CustomerFullDto>
    .NewConfig()
    .Map(dest => dest.HomeAddress, src => src.Address);
// CustomerFullDto has an AddressDto property

Manual nested:

C#
public static CustomerFullDto ToDto(this Customer c) => new(
    c.Id,
    c.Name,
    new AddressDto(c.Address.Street, c.Address.City, c.Address.PostCode)
);

Pick one approach per project and stick to it. Mixed strategies create confusion about where mapping lives.