DTOs and Mapping in ASP.NET Core Web API: Hand Mapping, the AutoMapper License and Mapperly
We see the cost of returning entities directly through real errors: overposting and circular references. Request/response DTOs, hand mapping and projection, AutoMapper's 2025 license change and CVE, and a short look at Mapperly.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
In the previous post we wrote ProductsController to take and return the Product entity directly. The database has two categories: Keyboards (1) and Mice (2). A client sends this request:
curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"USB-C Hub","price":890,"categoryId":1,"category":{"name":"Free Stuff"}}'
Two questions. Which status code does the client get? And when the request is over, what is the new product's CategoryId in the database?
The Cost of Returning Entities Directly
The answer is in these two lines:
HTTP/1.1 500 Internal Server Error
curl http://localhost:5080/api/products/3
{"id":3,"name":"USB-C Hub","price":890.0,"categoryId":3,"category":null}
The client got a 500, but the product was saved. And not into category 1, but into category 3. The category object in the body was bound to the entity's navigation property by model binding, EF Core saw it as a new category, and inserted a category called "Free Stuff". The product endpoint quietly turned into an endpoint that creates categories. This hole is called overposting: the client can fill in fields the API never meant to expose. Had the body contained "id":1, EF Core would have tried an INSERT with that Id and failed again with a 500 and UNIQUE constraint failed: Products.Id.
So where did the 500 come from? After the save, while the response was being written. The product's Category is set, and the category's Products list contains the same product. System.Text.Json goes round and round this loop and gives up at depth 32:
System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Id.
This is the same error you saw with Include(p => p.Category) in the last experiment of the previous post. Then there is the contract. Return the entity and your API's JSON shape is locked to your database schema: the day you rename a column, the mobile app breaks; the day you add an internal field such as a cost price to the entity, that field is exposed to everyone. It is also why we made Category nullable in the previous post; had we written Category Category { get; set; } = null!, every POST would have come back with this:
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-49c51f3540309834324d987d1ae26980-e045677d908859f7-00","errors":{"Category":["The Category field is required."]}}
The database model and the API contract serve different needs. The layout of the stations in a restaurant kitchen is not the same thing as the menu handed to the customer. A DTO (Data Transfer Object) is the menu.
Response and Request DTOs
For the shape going out, a record is enough:
namespace ProductCatalog.Dtos;
public record ProductDto(int Id, string Name, decimal Price, string CategoryName);
Instead of the whole category object, there is only its name. There is no chance of a cycle anymore, and the client gets what it needs in a single field. For incoming data we write separate classes:
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Dtos;
public class CreateProductRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[Range(0.01, 1000000)]
public decimal Price { get; set; }
[Range(1, int.MaxValue)]
public int CategoryId { get; set; }
}
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Dtos;
public class UpdateProductRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[Range(0.01, 1000000)]
public decimal Price { get; set; }
}
The request DTOs have no Id and no Category object. You now choose which fields the client can send; if an extra field shows up in the body, System.Text.Json silently ignores it. Having different classes for Create and Update is deliberate too: we do not allow changing the category on update. Two requests that look the same today drift apart tomorrow; splitting them early is cheaper than splitting them later. The validation attributes live here rather than on the entity, because these are the API's rules, not the database's.
Mapping by Hand
The simplest way to write the conversion between entity and DTO is a few extension methods:
using ProductCatalog.Dtos;
using ProductCatalog.Models;
namespace ProductCatalog.Mapping;
public static class ProductMappings
{
public static ProductDto ToDto(this Product product)
{
return new ProductDto(product.Id, product.Name, product.Price, product.Category?.Name ?? string.Empty);
}
public static Product ToEntity(this CreateProductRequest request)
{
return new Product
{
Name = request.Name,
Price = request.Price,
CategoryId = request.CategoryId
};
}
public static void ApplyTo(this UpdateProductRequest request, Product product)
{
product.Name = request.Name;
product.Price = request.Price;
}
}
On the read side, we put the mapping inside the query:
List<ProductDto> products = await _db.Products
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
.ToListAsync();
EF Core translates this Select into SQL and writes this to the console:
SELECT "p"."Id", "p"."Name", "p"."Price", "c"."Name"
FROM "Products" AS "p"
INNER JOIN "Categories" AS "c" ON "p"."CategoryId" = "c"."Id"
We did not write Include, yet a JOIN appeared, because the projection touched p.Category!.Name. Only four columns were read from the database. This is called a projection, and for read endpoints it is both faster and safer than loading entities and mapping them afterwards. The ! operator only silences the compiler; since the expression is translated to SQL, no null check happens at runtime.
Rewriting the Controller with DTOs
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
ProductDto? product = await _db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
.FirstOrDefaultAsync();
if (product is null)
{
return NotFound();
}
return product;
}
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
{
Category? category = await _db.Categories.FindAsync(request.CategoryId);
if (category is null)
{
ModelState.AddModelError(nameof(request.CategoryId), "Category does not exist.");
return ValidationProblem(ModelState);
}
Product product = request.ToEntity();
product.Category = category;
_db.Products.Add(product);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product.ToDto());
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateProductRequest request)
{
Product? product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
request.ApplyTo(product);
await _db.SaveChangesAsync();
return NoContent();
}
GetAll uses the projection above and Delete is the same as in the previous post. Let's send the request from the guessing question again, then try a category that does not exist:
HTTP/1.1 201 Created
Location: http://localhost:5080/api/Products/3
{"id":3,"name":"USB-C Hub","price":890,"categoryName":"Keyboards"}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-89556156a96fa39d471e9173f02ff161-b5b98d30dac9e766-00","errors":{"CategoryId":["Category does not exist."]}}
The id and category in the body were ignored, the product was attached to the requested category, and there is no cycle in the response. A missing category is no longer a 500 but a 400 in the same shape as [ApiController]'s validation errors. That is why ValidationProblem(ModelState) is useful; the client does not have to handle two kinds of errors separately.
The Same Job with AutoMapper
The objection to mapping by hand is obvious: in a project with dozens of DTOs you write the same assignments over and over. AutoMapper does these assignments itself using name-matching conventions. Version first; I will explain why in the next section:
dotnet add package AutoMapper --version 15.1.3
using AutoMapper;
using ProductCatalog.Dtos;
using ProductCatalog.Models;
namespace ProductCatalog.Mapping;
public class ProductProfile : Profile
{
public ProductProfile()
{
CreateMap<Product, ProductDto>();
CreateMap<CreateProductRequest, Product>()
.ForMember(d => d.Id, o => o.Ignore())
.ForMember(d => d.Category, o => o.Ignore());
}
}
builder.Services.AddAutoMapper(cfg =>
{
cfg.LicenseKey = builder.Configuration["AutoMapper:LicenseKey"];
}, typeof(Program));
typeof(Program) points to the assembly to scan for profiles. We wrote nothing for CategoryName in ProductDto: AutoMapper splits that name into Category.Name and finds it on its own. This is called flattening. In the controller we inject IMapper and use it in two places:
List<ProductDto> products = await _db.Products
.ProjectTo<ProductDto>(_mapper.ConfigurationProvider)
.ToListAsync();
Product product = _mapper.Map<Product>(request);
product.Category = category;
_db.Products.Add(product);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = product.Id }, _mapper.Map<ProductDto>(product));
ProjectTo produced exactly the same SQL as our hand-written Select; four columns and one JOIN again. Why are the two Ignore lines there? AutoMapper's most valuable feature is that it can catch missing mappings at startup. When I called app.Services.GetRequiredService<IMapper>().ConfigurationProvider.AssertConfigurationIsValid() in Program.cs without the Ignore lines, the app refused to start with this exception:
Unhandled exception. AutoMapper.AutoMapperConfigurationException:
Unmapped members were found. Review the types and members below.
Add a custom mapping expression, ignore, add a custom resolver, or modify the source/destination type
For no matching constructor, add a no-arg ctor, add optional arguments, or map all of the constructor parameters
==========================================================================
CreateProductRequest -> Product (Destination member list)
ProductCatalog.Dtos.CreateProductRequest -> ProductCatalog.Models.Product (Destination member list)
Unmapped properties:
Id
Category
For every destination field without a counterpart, you either write a rule or ignore it on purpose. Putting this check in a unit test is the cheapest way to catch mapping mistakes before they reach production.
AutoMapper's 2025 License Change
For years we added AutoMapper thinking "free, MIT-licensed." That changed in 2025; the information below comes from two announcements on Jimmy Bogard's own blog and from automapper.io. On 2 April 2025 Bogard announced that he would commercialize AutoMapper and MediatR. On 2 July 2025 the commercial editions launched and both projects moved to a company called Lucky Penny Software. AutoMapper 15.0 and later are dual-licensed: the Reciprocal Public License 1.5 (RPL-1.5) or Lucky Penny's commercial license. The commercial license comes in three paid tiers based on the number of developers. There is a free Community license for companies and individuals with annual revenue under 5 million dollars, non-profits with a budget under the same limit, educational use and non-production environments. Government agencies do not qualify, and there is an extra limit for companies that have taken large outside investment. Even with the Community license you are asked to get a license key.
If you do not provide a key, AutoMapper does not refuse to run; it logs a warning at startup:
warn: LuckyPennySoftware.AutoMapper.License[0]
You do not have a valid license key for the Lucky Penny software AutoMapper. This is allowed for development and testing scenarios. If you are running in production you are required to have a licensed version. Please visit https://luckypennysoftware.com to obtain a valid license.
Reading the key from configuration, as above, fits right into the user secrets and environment variable setup you saw in the configuration post. Versions before 15 stay under the MIT license they were released with. But before you say "I will just stay on 14," know this: CVE-2026-32933, published in March 2026 (high severity, a DoS that crashes the process through uncontrolled recursion on deeply nested objects), affects every version before 15.1.1 as well as 16.0.0 through 16.1.0; the fix is in 15.1.1 and 16.1.1. When I added 13.0.1, one of the last MIT-licensed versions, restore warned me plainly:
warning NU1903: Package 'AutoMapper' 13.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-rvv3-g6hj-g44x
That is why the example uses 15.1.3: it includes the fix, and through its netstandard2.0 target it builds without warnings on both net6.0 and net10.0. When I added 16.x to a net6.0 project, it pulled in the 10.0 versions of the Microsoft.Extensions packages, each with a "doesn't support net6.0" warning; in a project that targets only .NET 10, 16.2.0 is fine. There is an API change too: starting with 15, the old AddAutoMapper(typeof(Program)) call no longer compiles and gives CS1503; you need to pass a configuration action as shown above.
Mapperly, and Which One to Pick
There is a third way: Mapperly. It is an Apache-2.0-licensed source generator; it generates the mapping code at compile time, which means no reflection at runtime:
using ProductCatalog.Dtos;
using ProductCatalog.Models;
using Riok.Mapperly.Abstractions;
namespace ProductCatalog.Mapping;
[Mapper]
public static partial class ProductMapper
{
public static partial ProductDto ToProductDto(Product product);
public static partial IQueryable<ProductDto> ProjectToDto(this IQueryable<Product> query);
}
I built it with Riok.Mapperly 4.3.1 on both targets; it handled flattening on its own as well, and ProjectToDto produced the same SQL. It reports unmatched fields as compiler warnings:
warning RMG020: The member CategoryId on the mapping source type ProductCatalog.Models.Product is not mapped to any member on the mapping target type ProductCatalog.Dtos.ProductDto
Two options and a short tradeoff for choosing. Mapping by hand: no dependency, no license worries, everyone can read what happens, and when you add a field the record's constructor makes the compiler walk you to every call site; the cost is repetitive assignment code. A mapper library (AutoMapper or Mapperly): the repetitive code disappears and you move faster on large models; the cost is a new dependency, a license in AutoMapper's case, and "where did this field come from" no longer being answerable by reading the code. For an API the size of this series, my recommendation is mapping by hand; once the number of DTOs really grows, I would look at Mapperly, which works at compile time.
Common Pitfalls
First, calling the extension method inside the query. This code compiles and runs:
List<ProductDto> products = await _db.Products
.Select(p => p.ToDto())
.ToListAsync();
[{"id":1,"name":"Mechanical Keyboard","price":1450.0,"categoryName":""},{"id":2,"name":"Wireless Mouse","price":520.0,"categoryName":""}]
The category names came back empty. EF Core cannot translate the inside of ToDto to SQL; it reads the entity and calls the method in memory. Since Category was not loaded, it is null, and ?. quietly gives an empty string. Inside a query, either write an explicit new ProductDto(...) or use a tool that translates to SQL, such as ProjectTo.
Second, ReverseMap() in AutoMapper. A single line also creates the reverse map from DTO to entity and can bring overposting back. Always write a separate, explicit map for requests.
Third, putting an entity inside a DTO. Add public Category Category to ProductDto and every problem you kicked out the door climbs back in through the window; every type inside a DTO should be a DTO too.
Fourth, ToListAsync() first, then map. You pull every entity with every column into memory and convert afterwards. On read endpoints, make the projection part of the query.
A Small Experiment
★☆☆ Write a record called CategoryDto (Id, Name, ProductCount) and write the GET /api/categories endpoint with a projection. Use c.Products.Count for ProductCount and look at the SQL in the console.
★★☆ Add CategoryId to ProductDto. Count the lines the compiler sends you to with hand mapping. Make the same change in the AutoMapper version and see it work without touching a single line.
★★★ Write a mapping method with Mapperly that goes from CreateProductRequest to Product. When you build, read which RMG warnings you get for Id and Category, then ignore those two fields on purpose with attributes such as [MapperIgnoreTarget(nameof(Product.Id))] and watch the warnings disappear.
Our controller now queries the database, checks that the category exists, does the mapping and prepares the HTTP response. That is too much work for one action. In the next post we will move these responsibilities into a service layer, discuss whether the repository pattern is really needed on top of EF Core, and see what interfaces add to testability.