AutoMapper was the default answer for entity-to-DTO mapping in ASP.NET Core for a decade. Two things changed: in 2025 AutoMapper moved to a commercial license (free only below a revenue threshold), and the .NET community had already been drifting toward simpler options — hand-written mapping and compile-time source generators. This post covers all three, so you can pick deliberately: AutoMapper if you're licensed and invested, hand-written extension methods for most projects, or Mapperly when you want generated mappings with compile-time safety.

Why map to DTOs at all?
Returning EF Core entities straight from your API couples your public contract to your database schema — every column rename becomes a breaking API change, lazy-loaded navigation properties cause serialization surprises, and you leak fields you never meant to expose (timestamps, soft-delete flags, foreign keys). A DTO (data transfer object) is the shape your API promises; mapping is how entities become DTOs. The repository pattern tutorial shows this layering end to end.
The examples below map a Category entity to API models:
public class Category
{
public int CategoryId { get; set; }
public string CategoryName { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime CreatedAt { get; set; }
}
public record CategoryDto(int Id, string Name, string? Description);
public record CreateCategoryRequest(string Name, string? Description);
Option 1 — AutoMapper (the classic way)
dotnet add package AutoMapper
Define a profile and register it:
public class CategoryProfile : Profile
{
public CategoryProfile()
{
CreateMap<Category, CategoryDto>()
.ForCtorParam("Id", opt => opt.MapFrom(c => c.CategoryId))
.ForCtorParam("Name", opt => opt.MapFrom(c => c.CategoryName));
CreateMap<CreateCategoryRequest, Category>()
.ForMember(c => c.CategoryName, opt => opt.MapFrom(r => r.Name));
}
}
// Program.cs
builder.Services.AddAutoMapper(typeof(Program));
Then inject IMapper and call mapper.Map<CategoryDto>(entity) in the controller.
Know before you choose it:
- Licensing — AutoMapper is now commercial (per-developer licensing above a small-business revenue exemption). Check whether your organization qualifies before adding it to a new project.
- Runtime configuration — mappings are resolved with reflection at runtime. A typo in a property name surfaces as a runtime error (or a silently unmapped property), caught only if you run
AssertConfigurationIsValid()in a test. - It remains a perfectly good tool if you're already licensed and your team knows it — projection support (
ProjectTo) into EF Core queries is genuinely convenient.
Option 2 — hand-written extension methods (recommended default)
For most APIs, mapping is a few lines of assignment per type. Writing it yourself costs minutes and buys total clarity — F12 goes to real code, the compiler catches every mistake, and there is no package, license, or configuration:
public static class CategoryMappings
{
public static CategoryDto ToDto(this Category category) =>
new(category.CategoryId, category.CategoryName, category.Description);
public static Category ToEntity(this CreateCategoryRequest request) => new()
{
CategoryName = request.Name,
Description = request.Description,
CreatedAt = DateTime.UtcNow,
};
public static void ApplyTo(this CreateCategoryRequest request, Category category)
{
category.CategoryName = request.Name;
category.Description = request.Description;
}
}
Controller usage reads naturally:
[HttpGet("{id:int}")]
public async Task<ActionResult<CategoryDto>> Get(int id)
{
var category = await context.Categories.FindAsync(id);
return category is null ? NotFound() : category.ToDto();
}
[HttpPost]
public async Task<ActionResult<CategoryDto>> Create(CreateCategoryRequest request)
{
var category = request.ToEntity();
context.Categories.Add(category);
await context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = category.CategoryId }, category.ToDto());
}
For EF Core list queries, map inside Select so SQL fetches only the columns the DTO needs:
var dtos = await context.Categories
.Select(c => new CategoryDto(c.CategoryId, c.CategoryName, c.Description))
.ToListAsync();
This is the approach used in the repository pattern sample — it replaced AutoMapper there when the license changed.
Option 3 — Mapperly (compile-time generation)
If you have many types and want mappings generated for you without runtime reflection, Mapperly is a source generator: it writes the same code you would write by hand, at build time, so mistakes are compile errors and there is nothing to reflect over at runtime. It's MIT-licensed.
dotnet add package Riok.Mapperly
using Riok.Mapperly.Abstractions;
[Mapper]
public static partial class CategoryMapper
{
[MapProperty(nameof(Category.CategoryId), nameof(CategoryDto.Id))]
[MapProperty(nameof(Category.CategoryName), nameof(CategoryDto.Name))]
public static partial CategoryDto ToDto(Category category);
}
Same-named properties map automatically; [MapProperty] handles renames. Unmapped properties produce build warnings, so nothing slips through silently. You call it like any static method: CategoryMapper.ToDto(category).
Which one should you use?
| AutoMapper | Hand-written | Mapperly | |
|---|---|---|---|
| License | Commercial (free below revenue cap) | — | MIT |
| Errors caught | Runtime (or config test) | Compile time | Compile time |
| Extra dependency | Yes | No | Build-time only |
| Best for | Teams already invested + licensed | Most projects | Many types, want generation |
My default recommendation for new APIs: start hand-written. If the mapping layer grows past a couple dozen types of pure boilerplate, graduate to Mapperly — the migration is mechanical since it generates the same shape of code. Reach for AutoMapper only when you've confirmed the licensing fits and its dynamic features (custom value resolvers, ProjectTo) are actually needed.
Comments (0)
No comments yet — be the first to share your thoughts.