DTOs and Mapping

Why the shape you return is not the shape you store, how to map between them by hand, and what a leaked entity costs.

Syntaxpublic record BookResponse(int Id, string Title, string Author);

A DTO is a Data Transfer Object: a type that exists only to be the shape of a request or a response. An entity is a type that exists to be stored, usually one per database table. They look similar, so the temptation is to use one type for both. That is the coupling this lesson is about.

Why the two shapes differ

The stored shape holds things no caller should see: a password hash, a cost price, an internal note, a soft-delete flag. It also holds things a caller does not need, such as every row of a related table. And it is shaped for storage, not reading: a response often wants one flat object where the database has three. A request is different again. The client must not send the id of a thing it is about to create, nor the CreatedAt the server stamps. So you usually have three types: an entity, a request DTO, and a response DTO.

Mapping by hand

Write one static method per direction and call it from the endpoint. It is a few lines, the compiler checks it, and Find all references tells you every place a field is exposed. Older code often uses AutoMapper, a library that matches properties by name at run time; it is still common, and the cost is that a rename compiles cleanly and breaks the response. Hand mapping replaced it as the default advice.

What the leak costs

Return the entity and your database schema becomes your public contract. Add a column and clients see a new field. Rename one and every client breaks on a deploy you thought was internal.

Example

Example · csharp
using System.Text.Json;

// The entity: the shape that is stored. It holds things no caller should see.
Book stored = new()
{
    Id = 7,
    Title = "Dune",
    AuthorName = "Frank Herbert",
    CostPrice = 3.20m,
    InternalNotes = "reprint pending"
};

// The DTO: the shape that goes over the wire.
BookResponse response = BookMapper.ToResponse(stored);
Console.WriteLine(JsonSerializer.Serialize(response));

// Output:
// {"Id":7,"Title":"Dune","Author":"Frank Herbert"}

class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string AuthorName { get; set; } = "";
    public decimal CostPrice { get; set; }
    public string InternalNotes { get; set; } = "";
}

// A record is a good DTO: immutable, and the properties are the parameters.
record BookResponse(int Id, string Title, string Author);

static class BookMapper
{
    // One place that knows which fields are public, and under which names.
    public static BookResponse ToResponse(Book book) =>
        new(book.Id, book.Title, book.AuthorName);
}

When to use it

  • Adding a column to a table without that column appearing in the public API the next morning.
  • Stopping a password hash or a cost price from reaching a browser through a nested object.
  • Giving a mobile client one flat object instead of three nested ones it would have to stitch together.
  • Making it impossible for a client to set the Id or CreatedAt of a record it is creating.

More examples

A request DTO and a response DTO are different types

Using one DTO for both directions forces you to make Id nullable and then ignore it on the way in, which is a rule nothing enforces. Two types make the rule structural: there is no Id to send. Do this on every write endpoint where the server owns some of the fields.

Example · csharp
// The client supplies only what it is allowed to supply.
public record CreateBookRequest(string Title, string AuthorName);

// The server adds the id and the timestamp it owns.
public record BookResponse(int Id, string Title, string Author, string CreatedAt);

app.MapPost("/books", (CreateBookRequest request, IBookStore store) =>
{
    // No Id on the request, so a client cannot choose or overwrite one.
    Book created = store.Add(request.Title, request.AuthorName);
    BookResponse response = BookMapper.ToResponse(created);
    return Results.Created($"/books/{response.Id}", response);
});

Mapping as extension members, so it reads left to right

An extension member is a static method that can be called as if it were an instance method on its first parameter. The two commented lines are the same call written two ways. Reach for this when mapping is used in many endpoints and you want book.ToResponse() rather than a mapper name repeated everywhere.

Example · csharp
namespace Catalog.Mapping;

// Extension members must live in a static class.
public static class BookMappings
{
    public static BookResponse ToResponse(this Book book) =>
        new(book.Id, book.Title, book.AuthorName);

    // Longhand for the same thing, if you prefer the plain call:
    //     BookResponse response = BookMappings.ToResponse(book);
    // Shorthand, enabled by the "this" parameter above:
    //     BookResponse response = book.ToResponse();

    public static List<BookResponse> ToResponses(this IEnumerable<Book> books) =>
        books.Select(ToResponse).ToList();
}

Flattening three stored objects into one response

This is the case where returning the entity would be actively dangerous: Customer carries a password hash, and nesting it in the response publishes it. The DTO also computes Total once, server-side, instead of asking every client to add the lines up the same way.

Example · csharp
// Stored shape: an order points at a customer and a list of lines.
public record Order(int Id, Customer Customer, List<OrderLine> Lines);
public record Customer(int Id, string Name, string PasswordHash);
public record OrderLine(string Sku, int Quantity, decimal UnitPrice);

// Wire shape: one flat object with a figure the caller actually wanted.
public record OrderResponse(int Id, string CustomerName, decimal Total);

public static class OrderMappings
{
    public static OrderResponse ToResponse(Order order) =>
        new(order.Id,
            order.Customer.Name,               // PasswordHash is never copied
            order.Lines.Sum(l => l.Quantity * l.UnitPrice));
}

Discussion

  • Be the first to comment on this lesson.