TL;DR

  • AutoMapper 15 and later need a license (RPL 1.5 or commercial, with a free Community tier for companies under $5M revenue), and staying on 14 leaves the GHSA-rvv3-g6hj-g44x stack-overflow advisory unpatched.
  • Compare three paths across identical in-memory DTO mapping, EF Core SQL projections, and partial entity updates: AutoMapper 16, Mapster 10 (MIT), and zero-dependency manual mapping.
  • Every option ends with a test that fails when the mapping drifts, plus a migration path between them and a look at Riok.Mapperly, a source generator that catches unmapped members at build time.

Level: Intermediate · Prerequisites: ASP.NET Core Web API and EF Core experience with DTO-to-entity patterns · Reading time: 12 min

The build output has a new line: warning NU1903: Package 'AutoMapper' 14.0.0 has a known high severity vulnerability. The advisory (GHSA-rvv3-g6hj-g44x) describes a stack overflow when mapping deeply nested object graphs. The fixed versions are 15.1.1 and 16.1.1, and AutoMapper 15 and later need a license key. Version 14 was the last MIT release.

That warning turns a style debate (“do we even need a mapper?”) into a decision with a deadline. This article maps the same three things in AutoMapper, Mapster, and plain C#: an Order with lines to an OrderResponse with computed fields, the same mapping as an Entity Framework (EF) Core projection that runs in SQL, and an update from a request onto an existing Product that touches only the allowed fields. Each option ends with a test that fails when the mapping drifts.

The short answer

  • Choose AutoMapper 16 with a license when you have hundreds of maps, a team that knows its conventions, and the license cost (or a free Community license) is cheaper than rewriting them.
  • Choose Mapster when you want convention-based mapping and EF Core projections under the MIT license, with configuration that looks like AutoMapper’s but is shorter.
  • Choose manual mapping when you have tens of data transfer objects (DTOs), not hundreds, and want the compiler to catch every renamed property.
  • Choose Mapperly (covered in “Switching later”) when you want generated, reflection-free mapping code with build-time diagnostics.

What is being compared

AutoMapper is the convention-based object mapper by Jimmy Bogard, published by Lucky Penny Software. The current version is 16.2.0. Versions 15.0.0 and later are dual-licensed under the Reciprocal Public License 1.5 or a commercial license with a license key; versions before 15.0.0 were open source, and 14.0.0 was the last MIT release. Without a key, 15 and later log a warning and keep working, and non-production use is free under the license agreement.

Mapster is a convention-based mapper that compiles mappings into expression trees. The current version is 10.0.13, under the MIT license, maintained by a group of community contributors on the MapsterMapper GitHub organization. Version 10.0.0 (March 2026) was the first major release after 7.4.0. Mapster.DependencyInjection adds an injectable IMapper.

Manual mapping is C# you write yourself: an expression for projections, an extension method for in-memory mapping, and plain assignments for updates. No package, no license, and no runtime magic.

The same task in each option

Three call sites, identical in every option:

  1. ToResponse(order): map a loaded Order (with Lines) to OrderResponse in memory, for example after placing an order.
  2. ListOrdersAsync(userId): return the user’s orders as OrderResponse, projected in SQL, without loading entities.
  3. UpdateProductAsync(id, request): copy Name and Price from UpdateProductRequest onto a tracked Product, never touching Id or Stock.

Shared code used by all three options

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// File: Orders/OrderResponse.cs
namespace Store.Api.Orders;

public sealed class OrderResponse
{
    public required int Id { get; init; }
    public required DateTime CreatedAtUtc { get; init; }
    public required OrderStatus Status { get; init; }
    public required decimal Total { get; init; }
    public required int ItemCount { get; init; }
    public required List<OrderLineResponse> Lines { get; init; }
}

public sealed class OrderLineResponse
{
    public required int ProductId { get; init; }
    public required int Quantity { get; init; }
    public required decimal UnitPrice { get; init; }
    public required decimal LineTotal { get; init; }
}
1
2
3
4
// File: Products/UpdateProductRequest.cs
namespace Store.Api.Products;

public sealed record UpdateProductRequest(string Name, decimal Price);

ItemCount (sum of quantities) and LineTotal (quantity times unit price) have no matching entity property, so every option must configure them. Order, OrderLine, and Product are the shared Store.Api entities. The required modifiers matter for the manual option; the libraries ignore them.

Each option provides a StoreService with the same public methods, so one controller works with all three:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// File: Orders/StoreController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Store.Api.Auth;
using Store.Api.Mapping;
using Store.Api.Products;

namespace Store.Api.Orders;

[ApiController]
[Authorize]
[Route("api")]
public sealed class StoreController(StoreService store, ICurrentUser currentUser) : ControllerBase
{
    [HttpGet("orders/mine")]
    public Task<List<OrderResponse>> MyOrders(CancellationToken ct) =>
        store.ListOrdersAsync(currentUser.UserId!, ct);

    [HttpPut("products/{id:int}")]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> UpdateProduct(int id, UpdateProductRequest request, CancellationToken ct) =>
        await store.UpdateProductAsync(id, request, ct) ? NoContent() : NotFound();
}

ICurrentUser comes from the playbook guide “Issuing and Validating JWT Access Tokens in ASP.NET Core”. Register the service with builder.Services.AddScoped<StoreService>(); in every option.

AutoMapper

1
dotnet add package AutoMapper
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// File: Mapping/OrderMappingProfile.cs
using AutoMapper;
using Store.Api.Orders;
using Store.Api.Products;

namespace Store.Api.Mapping;

public sealed class OrderMappingProfile : Profile
{
    public OrderMappingProfile()
    {
        CreateMap<OrderLine, OrderLineResponse>()
            .ForMember(d => d.LineTotal, o => o.MapFrom(s => s.Quantity * s.UnitPrice));

        CreateMap<Order, OrderResponse>()
            .ForMember(d => d.ItemCount, o => o.MapFrom(s => s.Lines.Sum(l => l.Quantity)));

        CreateMap<UpdateProductRequest, Product>(MemberList.Source);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// File: Mapping/StoreService.cs
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
using Store.Api.Orders;
using Store.Api.Products;

namespace Store.Api.Mapping;

public sealed class StoreService(AppDbContext db, IMapper mapper)
{
    public OrderResponse ToResponse(Order order) => mapper.Map<OrderResponse>(order);

    public Task<List<OrderResponse>> ListOrdersAsync(string userId, CancellationToken ct) =>
        db.Orders.AsNoTracking()
            .Where(o => o.UserId == userId)
            .OrderByDescending(o => o.CreatedAtUtc)
            .ProjectTo<OrderResponse>(mapper.ConfigurationProvider)
            .ToListAsync(ct);

    public async Task<bool> UpdateProductAsync(int id, UpdateProductRequest request, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return false;

        mapper.Map(request, product);
        await db.SaveChangesAsync(ct);
        return true;
    }
}
1
2
3
4
// Program.cs, before builder.Build() (add using Store.Api.Mapping;)
builder.Services.AddAutoMapper(
    cfg => cfg.LicenseKey = builder.Configuration["AutoMapper:LicenseKey"], // 15+ only; use cfg => { } on 14
    typeof(OrderMappingProfile));
  • MapFrom expressions serve both in-memory mapping and ProjectTo, which turns them into SQL.
  • MemberList.Source flips validation for the update map: every source member must be used, and unmatched destination members (Id, Stock) are left alone. With the default MemberList.Destination, validation fails on Id and Stock, and the usual “fix” (ignoring everything) hides real mistakes.
  • The key can also come from the AUTOMAPPER_LICENSE_KEY or LUCKYPENNY_LICENSE_KEY environment variables.

The configuration test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// File: tests/Store.Api.Tests/AutoMapperConfigurationTests.cs
using AutoMapper;
using Microsoft.Extensions.DependencyInjection;
using Store.Api.Mapping;
using Xunit;

namespace Store.Api.Tests;

public sealed class AutoMapperConfigurationTests
{
    [Fact]
    public void Configuration_is_valid()
    {
        using var provider = new ServiceCollection()
            .AddLogging()
            .AddAutoMapper(cfg => { }, typeof(OrderMappingProfile))
            .BuildServiceProvider();

        provider.GetRequiredService<AutoMapper.IConfigurationProvider>().AssertConfigurationIsValid();
    }
}

Building the configuration through dependency injection (DI) works on 14 and 16. Since 15, MapperConfiguration needs an ILoggerFactory, which is why the test calls AddLogging(). Add a destination property without a source and the test fails with “Unmapped members were found.”

Mapster

1
2
dotnet add package Mapster
dotnet add package Mapster.DependencyInjection
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// File: Mapping/OrderMappingRegister.cs
using Mapster;
using Store.Api.Orders;
using Store.Api.Products;

namespace Store.Api.Mapping;

public sealed class OrderMappingRegister : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<OrderLine, OrderLineResponse>()
            .Map(d => d.LineTotal, s => s.Quantity * s.UnitPrice);

        config.NewConfig<Order, OrderResponse>()
            .Map(d => d.ItemCount, s => s.Lines.Sum(l => l.Quantity));

        config.NewConfig<UpdateProductRequest, Product>()
            .Ignore(d => d.Id, d => d.Stock);
    }
}

public static class MapsterSetup
{
    public static TypeAdapterConfig CreateConfig()
    {
        var config = new TypeAdapterConfig { RequireDestinationMemberSource = true };
        config.Scan(typeof(MapsterSetup).Assembly);
        return config;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// File: Mapping/StoreService.cs
using Mapster;
using MapsterMapper;
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
using Store.Api.Orders;
using Store.Api.Products;

namespace Store.Api.Mapping;

public sealed class StoreService(AppDbContext db, IMapper mapper, TypeAdapterConfig config)
{
    public OrderResponse ToResponse(Order order) => mapper.Map<OrderResponse>(order);

    public Task<List<OrderResponse>> ListOrdersAsync(string userId, CancellationToken ct) =>
        db.Orders.AsNoTracking()
            .Where(o => o.UserId == userId)
            .OrderByDescending(o => o.CreatedAtUtc)
            .ProjectToType<OrderResponse>(config)
            .ToListAsync(ct);

    public async Task<bool> UpdateProductAsync(int id, UpdateProductRequest request, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return false;

        mapper.Map(request, product);
        await db.SaveChangesAsync(ct);
        return true;
    }
}
1
2
3
// Program.cs, before builder.Build() (add using Mapster; using MapsterMapper; using Store.Api.Mapping;)
builder.Services.AddSingleton(MapsterSetup.CreateConfig());
builder.Services.AddScoped<IMapper, ServiceMapper>();
  • RequireDestinationMemberSource = true makes Mapster reject any destination member without a source, which is the same safety net AutoMapper validation gives you. The update map then has to name Id and Stock in Ignore, so the allowed fields are visible in one place.
  • Mapping onto an existing object (mapper.Map(request, product)) only writes the mapped members.
  • Pass the same config to ProjectToType. Without it, Mapster uses TypeAdapterConfig.GlobalSettings, which does not contain your registrations.

The configuration test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// File: tests/Store.Api.Tests/MapsterConfigurationTests.cs
using Store.Api.Mapping;
using Xunit;

namespace Store.Api.Tests;

public sealed class MapsterConfigurationTests
{
    [Fact]
    public void Configuration_compiles()
    {
        var config = MapsterSetup.CreateConfig();

        config.Compile();
        config.CompileProjection();
    }
}

Compile() builds every registered map (both “new object” and “onto existing object”) and throws on the first problem. A missing source member fails with a CompileException whose message lists it: “The following members of destination class … do not have a corresponding source member mapped or ignored”.

Manual mapping

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// File: Mapping/OrderMappings.cs
using System.Linq.Expressions;
using Store.Api.Orders;
using Store.Api.Products;

namespace Store.Api.Mapping;

public static class OrderMappings
{
    public static readonly Expression<Func<Order, OrderResponse>> ToResponseExpression = o => new OrderResponse
    {
        Id = o.Id,
        CreatedAtUtc = o.CreatedAtUtc,
        Status = o.Status,
        Total = o.Total,
        ItemCount = o.Lines.Sum(l => l.Quantity),
        Lines = o.Lines.Select(l => new OrderLineResponse
        {
            ProductId = l.ProductId,
            Quantity = l.Quantity,
            UnitPrice = l.UnitPrice,
            LineTotal = l.Quantity * l.UnitPrice
        }).ToList()
    };

    private static readonly Func<Order, OrderResponse> Compiled = ToResponseExpression.Compile();

    public static OrderResponse ToResponse(this Order order) => Compiled(order);

    public static void ApplyTo(this UpdateProductRequest request, Product product)
    {
        product.Name = request.Name;
        product.Price = request.Price;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// File: Mapping/StoreService.cs
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
using Store.Api.Orders;
using Store.Api.Products;

namespace Store.Api.Mapping;

public sealed class StoreService(AppDbContext db)
{
    public OrderResponse ToResponse(Order order) => order.ToResponse();

    public Task<List<OrderResponse>> ListOrdersAsync(string userId, CancellationToken ct) =>
        db.Orders.AsNoTracking()
            .Where(o => o.UserId == userId)
            .OrderByDescending(o => o.CreatedAtUtc)
            .Select(OrderMappings.ToResponseExpression)
            .ToListAsync(ct);

    public async Task<bool> UpdateProductAsync(int id, UpdateProductRequest request, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return false;

        request.ApplyTo(product);
        await db.SaveChangesAsync(ct);
        return true;
    }
}
  • One expression serves both EF Core (Select(OrderMappings.ToResponseExpression)) and in-memory mapping (compiled once into a delegate), so the two can never disagree.
  • Because every response property is required, forgetting one in the object initializer is a compile error (CS9035). That is the manual option’s configuration validation, and it runs on every build.
  • ApplyTo is the allow list. A new Product property stays untouched until someone writes a line for it.

The test checks behavior, because the compiler already checks completeness:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// File: tests/Store.Api.Tests/ManualMappingTests.cs
using Store.Api.Mapping;
using Store.Api.Orders;
using Xunit;

namespace Store.Api.Tests;

public sealed class ManualMappingTests
{
    [Fact]
    public void Computes_item_count_and_line_totals()
    {
        var order = new Order
        {
            Id = 7, Status = OrderStatus.Paid, Total = 33.50m,
            Lines = [new OrderLine { ProductId = 1, Quantity = 2, UnitPrice = 12.50m },
                     new OrderLine { ProductId = 2, Quantity = 2, UnitPrice = 4.25m }]
        };

        var response = order.ToResponse();

        Assert.Equal(4, response.ItemCount);
        Assert.Equal(8.50m, response.Lines[1].LineTotal);
    }
}

All three options produced the same SQL in a run against SQL Server LocalDB with EF Core 9 (AutoMapper only reorders the columns). The update writes only the allowed columns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
SELECT [o].[Id], [o].[CreatedAtUtc], [o].[Status], [o].[Total], (
    SELECT COALESCE(SUM([o0].[Quantity]), 0)
    FROM [OrderLines] AS [o0]
    WHERE [o].[Id] = [o0].[OrderId]), [o1].[ProductId], [o1].[Quantity], [o1].[UnitPrice],
    CAST([o1].[Quantity] AS decimal(18,2)) * [o1].[UnitPrice], [o1].[Id]
FROM [Orders] AS [o]
LEFT JOIN [OrderLines] AS [o1] ON [o].[Id] = [o1].[OrderId]
WHERE [o].[UserId] = @__userId_0
ORDER BY [o].[CreatedAtUtc] DESC, [o].[Id]

UPDATE [Products] SET [Name] = @p0, [Price] = @p1
OUTPUT 1
WHERE [Id] = @p2;

Side by side

CriterionAutoMapper 14 / 16Mapster 10Manual mapping
Setup effortLow: profile plus AddAutoMapperLow: IRegister plus two registrationsNone; you write each map
Learning curveMedium: conventions, MemberList, resolversMedium: similar concepts, fewer of themNone beyond C# and expression trees
Code per new DTOLowest when names matchLowest when names matchOne line per property
Mistake detectionRuntime test with AssertConfigurationIsValidRuntime test with Compile() and strict settingsCompiler, through required members
EF Core projectionProjectToProjectToTypeSelect(expression)
Update onto entityMap(source, destination) with MemberList.SourceMap(source, destination) with IgnoreExplicit assignments
Runtime performanceCompiled expression trees, cached per mapCompiled expression trees, cached per mapPlain code; nothing to build
DebuggingHard to step into generated mapsSameOrdinary breakpoints
Refactoring safetySilent breakage without the testSilent breakage without the testRename and the build fails
License and cost14 MIT (unpatched advisory); 15+ RPL 1.5 or commercialMIT, freeFree
MaintenanceActive, commercialActive, community maintainersYou

How to choose

  • Tens of DTOs, not hundreds. Manual mapping. The code is boring, reviewable, and the compiler does the validation.
  • Hundreds of maps that mostly match by name. Price the AutoMapper license against migration effort. Companies under 5 million US dollars in gross annual revenue may qualify for the free Community license, with further conditions in the license text; Lucky Penny decides eligibility, so check the current terms.
  • You want conventions but not the license. Mapster. The concepts carry over, and ProjectToType covers EF Core.
  • You cannot upgrade AutoMapper this sprint. Staying on 14.0.0 means living with the advisory. If you do, at least avoid mapping self-referencing types from untrusted input and add MaxDepth to recursive maps.

Switching later

AutoMapper to Mapster. CreateMap<A, B>() becomes config.NewConfig<A, B>(), ForMember(d => d.X, o => o.MapFrom(s => ...)) becomes .Map(d => d.X, s => ...), Ignore() becomes .Ignore(d => d.X), and ProjectTo<T>(mapper.ConfigurationProvider) becomes ProjectToType<T>(config). Port the validation test first, then the profiles one at a time; both libraries can run side by side because their IMapper interfaces live in different namespaces (AutoMapper and MapsterMapper).

Any library to manual mapping. Do it per endpoint. Write the expression, switch the query to Select(...), and delete the map. Mark DTO properties required as you go so the compiler starts protecting you immediately.

Upgrading Mapster from 7.4 to 10. Read the 10.0.0 release notes before you bump; it changed how records are detected and mapped onto existing instances. Run the Compile() test and your mapping tests after the upgrade.

A fourth option: Mapperly. Mapperly (Riok.Mapperly, version 4.3.1, Apache 2.0 license) is a source generator: you declare partial methods and it writes the mapping code at build time, with no runtime reflection. Unmapped members show up as build diagnostics you can promote to errors.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// File: Mapping/ProductMapper.cs
using Riok.Mapperly.Abstractions;
using Store.Api.Products;

namespace Store.Api.Mapping;

[Mapper]
public static partial class ProductMapper
{
    [MapperIgnoreTarget(nameof(Product.Id))]
    [MapperIgnoreTarget(nameof(Product.Stock))]
    public static partial void ApplyTo(this UpdateProductRequest request, Product product);
}

It also generates IQueryable<T> projections from a method such as public static partial IQueryable<OrderResponse> ProjectToResponse(this IQueryable<Order> query);, and computed members use [MapPropertyFromSource(..., Use = nameof(...))]. Moving from manual mapping to Mapperly is the easiest path of all, because the generated code looks like what you already wrote.

Common pitfalls

  • Ignoring all unmapped members to make validation pass. It turns the test into a no-op. Use MemberList.Source or explicit Ignore calls instead.
  • Mapping in memory instead of projecting. mapper.Map<List<OrderResponse>>(await db.Orders.Include(o => o.Lines).ToListAsync()) loads full entities and tracks them. Use ProjectTo, ProjectToType, or Select.
  • Methods that EF Core cannot translate. A MapFrom(s => FormatPrice(s.Total)) works in memory and fails or runs client-side in a projection. Keep projection expressions to members and simple operators.
  • Mapping requests straight onto entities without an allow list. A request DTO that gains a Stock property would overwrite stock on the next deploy. Keep the update map explicit.
  • Forgetting the ILoggerFactory in AutoMapper 15+ tests. new MapperConfiguration(cfg => ...) without a logger factory no longer compiles; build through DI with AddLogging() as above.
  • IConfigurationProvider name clash. ASP.NET Core projects import Microsoft.Extensions.Configuration, which has its own IConfigurationProvider. Qualify it as AutoMapper.IConfigurationProvider.
  • DTOs and Mapping: Manual Mapping, Mapster, and AutoMapper (in “The .NET Implementation Playbook”)
  • Read-Only Queries with AsNoTracking and Projections (in “The .NET Implementation Playbook”)
  • Replacing AutoMapper and MediatR After the License Change
  • MediatR vs Mediator vs Plain Handler Classes
  • EF Core Performance Pitfalls: N+1, Tracking, and Projections (in “Zero to Full-Stack .NET”)

Part of The .NET Field Guide. Previous: MassTransit vs Wolverine vs Raw RabbitMQ.Client After the MassTransit License Change. Next in the series: Replacing AutoMapper and MediatR After the License Change (coming soon).

Get the next articles by email: subscribe to the newsletter.

How this article was made: drafted with AI assistance, then reviewed by Softinbit Technology before publishing.