DTOs in .NET · Part 2

When a DTO Is Worth Writing: Composition, Boundaries and CQRS with MediatR

The 1-to-1 mapping trap, the two jobs a DTO actually does, and how MediatR queries and commands replace CRUD wrappers around database tables.

The previous post covered how a DTO should look: no behavior, public properties, named by role, one type per boundary. This one is about a question that comes before any of that: whether the DTO should exist at all.

Open a typical .NET API and count. For every EF Core entity there is a matching EntityDto with the same properties in the same order, an AutoMapper profile that copies one into the other, and a controller with GET, POST, PUT and DELETE that moves the DTO in and out of the table. Adding a column means touching the entity, the DTO, the profile, the migration and usually the client. Nothing in that chain protects anything, and nothing in it composes anything. It is a tax paid for a benefit that never arrives.

The examples below use a small logistics domain: shipments, customers and drivers. The application layer uses MediatR, the same way the registration flow in Part 1 did, because the request and response types it dispatches are exactly the DTOs this post is about.

The Confusion Two things called "entity"

Most 1-to-1 DTOs are written to avoid "exposing entities". The reasoning is sound in the abstract and collapses in practice, because the word covers two different things:

  • ORM entity. The class EF Core maps to a table. Properties mirror columns, navigation properties mirror foreign keys, and the shape is dictated by storage.
  • Domain entity. A class in the domain-driven design sense: state plus the behavior that changes it, with invariants enforced inside the type.

// ORM entity: shape follows the table
public class Shipment
{
    public int Id { get; set; }
    public string TrackingNumber { get; set; } = default!;
    public ShipmentStatus Status { get; set; }
    public DateTime ScheduledPickupUtc { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = default!;
    public int? DriverId { get; set; }
    public Driver? Driver { get; set; }
}

// Domain entity: shape follows the rules
public class Shipment
{
    public int Id { get; private set; }
    public ShipmentStatus Status { get; private set; }
    public string? CancellationReason { get; private set; }

    public void Cancel(string reason)
    {
        if (Status != ShipmentStatus.Scheduled)
            throw new InvalidOperationException(
                $"Shipment {Id} cannot be cancelled in status {Status}.");

        Status = ShipmentStatus.Cancelled;
        CancellationReason = reason;
    }
}

    

The concern with the first one is leaking the database schema to callers. The concern with the second one is leaking business logic and private setters into a serialized contract. These are different risks with different fixes, and a DTO that copies every property of the ORM entity addresses neither. It leaks the schema anyway, just through a second class.

The Trap 1-to-1 mapping and entity services

The pattern looks like this, repeated once per table:


public class ShipmentDto
{
    public int Id { get; set; }
    public string TrackingNumber { get; set; } = default!;
    public ShipmentStatus Status { get; set; }
    public DateTime ScheduledPickupUtc { get; set; }
    public int CustomerId { get; set; }
    public int? DriverId { get; set; }
}

public class ShipmentProfile : Profile
{
    public ShipmentProfile()
    {
        CreateMap<Shipment, ShipmentDto>().ReverseMap();
    }
}

[HttpGet("/api/shipments/{id}")]
public async Task<ActionResult<ShipmentDto>> Get(int id) =>
    _mapper.Map<ShipmentDto>(await _db.Shipments.FindAsync(id));

[HttpPut("/api/shipments/{id}")]
public async Task<IActionResult> Update(int id, ShipmentDto dto)
{
    var shipment = await _db.Shipments.FindAsync(id);
    _mapper.Map(dto, shipment);          // every column is now writable from outside
    await _db.SaveChangesAsync();
    return NoContent();
}

    

This is an entity service: an HTTP wrapper around a table. The client can set any column to any value through PUT, so the rule "only scheduled shipments can be cancelled" has to live in the client, or in a validator that re-derives what the domain entity already knew, or nowhere. The DTO gave the API a second copy of the schema and took away the ability to express what the caller actually wanted to do.

Change Files touched with 1-to-1 DTOs Value the DTO layer added
Add a column DeliveryNotes Entity, migration, DTO, mapping profile, client model None. The column reaches the client unchanged.
Rename ScheduledPickupUtc Entity, migration, DTO, profile, every client None. The rename leaks through the DTO by construction.
Split Driver into its own service Entity, DTO, profile, client, plus the new service None. The DTO still carries DriverId.

The Two Jobs What a DTO is actually for

A DTO adds value in exactly two situations. If a type does neither, it is a speculative abstraction and the maintenance cost is pure overhead.

Shipment table Customer table Driver table composition one query ShipmentDetails (query result) shipmenttracking number, status, pickup date customername, phone drivername, last known location actionsCanCancel, CanMarkArrived decided on the server, once, for every client boundary: the only shape the client sees public API / UI view
One query composes three tables into the shape the screen needs, and that shape is the only thing that crosses the boundary.

Job 1: composition

A screen almost never shows one table. The shipment details page shows the shipment, the customer it belongs to, the driver assigned to it, and which buttons the user is allowed to press right now. That shape exists nowhere in the database. Building it is the DTO's job, and with MediatR the query handler is where it gets built:


public record GetShipmentDetailsQuery(int ShipmentId) : IRequest<ShipmentDetails?>;

public record ShipmentDetails(
    int Id,
    string TrackingNumber,
    ShipmentStatus Status,
    DateTime ScheduledPickupUtc,
    string CustomerName,
    string CustomerPhone,
    string? DriverName,
    string? DriverLastLocation,
    bool CanCancel,
    bool CanMarkArrived);

public sealed class GetShipmentDetailsHandler(AppDbContext db)
    : IRequestHandler<GetShipmentDetailsQuery, ShipmentDetails?>
{
    public Task<ShipmentDetails?> Handle(GetShipmentDetailsQuery query, CancellationToken ct) =>
        db.Shipments
            .Where(s => s.Id == query.ShipmentId)
            .Select(s => new ShipmentDetails(
                s.Id,
                s.TrackingNumber,
                s.Status,
                s.ScheduledPickupUtc,
                s.Customer.Name,
                s.Customer.Phone,
                s.Driver != null ? s.Driver.FullName : null,
                s.Driver != null ? s.Driver.LastKnownLocation : null,
                // decided here, once, instead of in every client
                s.Status == ShipmentStatus.Scheduled,
                s.Status == ShipmentStatus.InTransit))
            .SingleOrDefaultAsync(ct);
}

    

Three tables, one round trip, one type shaped for one screen. The two boolean flags at the end are the part most teams leave out. They are hypermedia in its simplest form: the server tells the client which actions are valid, so the rule for "can this be cancelled" has one home. When the rule changes, the client does not.

This DTO is also the reason the ORM entity did not need a twin. The entity stays inside, the projection goes outside, and there is no property-by-property copy in between.

Job 2: managing coupling

The second job is isolation, and the useful distinction is between data on the inside and data on the outside.

Data on the inside Data on the outside
Examples EF Core entities, table schema, internal projections, in-process commands Public API responses, events on a message bus, files handed to partners
Who depends on it Code you can change in the same pull request Teams, services and customers you cannot
Can it change freely Yes, rename and refactor at will No, every change is a versioning event
Needs a dedicated contract type Only when composition demands one Always

An event published to a bus is the clearest case of data on the outside. Consumers you have never met will deserialize it for years. It gets its own type, with only the fields consumers need, and it never shares a class with the entity:


// Outside: consumed by billing, notifications and a partner integration.
// Small on purpose. Adding a field is safe, renaming or removing one is a breaking change.
public record ShipmentCancelled(
    int ShipmentId,
    string TrackingNumber,
    string Reason,
    DateTime CancelledUtc);

    

Rule 1 Stop writing "just in case" DTOs

The usual defense of the 1-to-1 copy is that the schema might change one day and the DTO will absorb the change. It will not. A DTO that mirrors the entity mirrors the change too, and the mapping profile is the first thing that breaks. The insulation is imaginary, and the cost of maintaining it is real.

If the shape a consumer needs is the shape the entity already has, and you own that consumer, hand it the entity or a projection and move on. Write the DTO when the shape diverges, not before.


// Internal admin page in the same solution, same team, two call sites.
// No DTO, no profile, no controller. The page handler sends the query directly.
public sealed class ListShipmentsHandler(AppDbContext db)
    : IRequestHandler<ListShipmentsQuery, IReadOnlyList<ShipmentRow>>
{
    public async Task<IReadOnlyList<ShipmentRow>> Handle(ListShipmentsQuery q, CancellationToken ct) =>
        await db.Shipments
            .Where(s => s.Status == q.Status)
            .OrderBy(s => s.ScheduledPickupUtc)
            .Select(s => new ShipmentRow(s.Id, s.TrackingNumber, s.ScheduledPickupUtc, s.Customer.Name))
            .ToListAsync(ct);
}

    

ShipmentRow exists because the grid needs the customer name, which is composition. It does not exist to hide Shipment from a Razor page that lives three folders away.

Rule 2 Own your consumers, or publish a contract

The question that decides whether a boundary type is needed is not "is this an entity" but "who calls this and can I change them". Count the references.

  • Two references, both in this repository. Coupling is cheap. A rename is a find-and-replace and a single pull request. Return the projection, skip the contract.
  • Fifty references, across services owned by four teams. Coupling is expensive. Every field is a promise. Publish a versioned contract and keep the internal model behind it.
  • Any reference from a party outside the company. Always a contract, no exceptions, because you will never be able to coordinate the change.
Do not let externals you do not control couple to internals you do control. That single sentence covers every case above. Everything inside that line is free to change. Everything that crosses it gets a stable, versioned type.

Rule 3 Model tasks, not tables

The PUT endpoint from the trap section is the root cause of most 1-to-1 DTOs. Once the API is "update the shipment row", the DTO has to carry every column, because any column might be the one being updated. Model the task instead and the DTO shrinks to what the task needs.


// Command: captures intent, carries only what the intent needs
public record CancelShipmentCommand(int ShipmentId, string Reason) : IRequest;

public sealed class CancelShipmentHandler(AppDbContext db, IPublisher publisher)
    : IRequestHandler<CancelShipmentCommand>
{
    public async Task Handle(CancelShipmentCommand command, CancellationToken ct)
    {
        var shipment = await db.Shipments.SingleAsync(s => s.Id == command.ShipmentId, ct);

        shipment.Cancel(command.Reason);        // the rule lives on the domain entity

        await db.SaveChangesAsync(ct);

        await publisher.Publish(new ShipmentCancelled(
            shipment.Id, shipment.TrackingNumber, command.Reason, DateTime.UtcNow), ct);
    }
}

// Endpoint: one route per task instead of one PUT per table
[HttpPost("/api/shipments/{id}/cancel")]
public async Task<IActionResult> Cancel(int id, CancelShipmentRequest request, CancellationToken ct)
{
    await _mediator.Send(new CancelShipmentCommand(id, request.Reason), ct);
    return NoContent();
}

    

The client cannot set the status to Cancelled on an in-transit shipment, because there is no field for status. It can only ask for a cancellation, and the domain entity decides. The reason is recorded, the event is published with the fields that matter, and the endpoint documents itself.

About the near-identical request and command: CancelShipmentRequest and CancelShipmentCommand carry the same two values, which looks like the duplication Rule 1 warned against. It is not. The request is data on the outside, part of the HTTP contract. The command is data on the inside, dispatched in-process. They are identical today and will change for different reasons: the request when the API is versioned, the command when the handler needs the acting user's id. When the caller is internal, such as a Razor page in the same app, skip the request entirely and send the command from the page handler.

Queries follow the same logic. GetShipmentDetailsQuery returns the shape of the details screen, including the allowed actions. A separate ListShipmentsQuery returns grid rows. Neither returns "the shipment", because no screen wants exactly that.

None of this depends on MediatR specifically. The same shape works with a hand-written IRequestHandler interface, with Wolverine, or with plain application service classes. What matters is that each entry point is a task with its own input and output types, and that those types are the only DTOs the feature needs.

Summary When to write the DTO

Situation Write a DTO Reason
Screen needs data from several tables Yes, as a query result Composition. The shape does not exist in storage.
Response includes allowed actions or derived flags Yes, as a query result Composition. The rule gets one home on the server.
Public API consumed by other teams or customers Yes, versioned Boundary. Consumers you cannot change.
Event on a message bus Yes, minimal fields Boundary. Consumers you have not met yet.
User performs an action (cancel, assign, mark arrived) Yes, as a command Intent. Carries only what the task needs.
Internal consumer, same team, shape matches the entity No Nothing to compose, nothing to protect.
"The schema might change some day" No A mirror image changes with the original.

A DTO is a tool for composing data and for isolating a boundary. Used for either, it pays for itself on the first schema change. Used as a reflexive copy of every entity, it doubles the surface area of the codebase and protects nothing. The five rules from Part 1 say how to write one. These three say whether to.

← Part 1: 5 Rules for Writing Better DTOs in .NET


More posts
← All posts  ·  RSS © 2026 DotNET Leet