DTOs in .NET · Part 1 · Part 2

5 Rules for Writing Better DTOs in .NET (and the Most Common Traps)

Record or class, properties or fields, where the Dto suffix belongs, and why DataAnnotations silently pass on positional records.

Data Transfer Objects are one of the most basic building blocks in .NET code, and they are still designed wrong surprisingly often. The same handful of mistakes shows up in code review again and again: a DTO that calculates its own totals, a DTO with public fields that serializes to an empty object, a CreateUserRequestDto that is reused as a message on the bus, and validation attributes that the validator never sees.

Definition What a DTO actually is

A DTO is an object whose only job is to carry data. It exists so that a set of values can be serialized (to JSON, XML, a message body) and moved across a boundary: over the network, out of a database, or between layers of the same application.

The receiving side never sees your class. It sees a payload. Whether that payload was produced by a record, a class, or an anonymous object is irrelevant to the consumer. Only the values and their names survive the trip. Every rule below follows from that one fact.

Rule 1 A DTO has no logic and no behavior

If it has behavior, it is not a DTO.

Methods on a DTO do not travel with the data. The frontend, the other microservice, or the message consumer receives the serialized values and nothing else. Any calculation, validation method or business rule you put on the type only runs on the side that already has the class, which means it either runs twice (once per side, with two implementations that will drift) or it runs in a place where it does not belong.

Here is the kind of DTO that grows over time when nobody enforces the rule:


// BAD: behavior on a transfer object
public class OrderDto
{
    public int Id { get; set; }
    public string OrderNumber { get; set; } = default!;
    public List<OrderLineDto> Lines { get; set; } = new();
    public decimal DiscountPercent { get; set; }

    public decimal CalculateTotal()
    {
        var subtotal = Lines.Sum(l => l.Quantity * l.UnitPrice);
        return subtotal * (1 - DiscountPercent / 100m);
    }

    public bool IsValid() => Lines.Count > 0 && DiscountPercent <= 30;
}

    

CalculateTotal is pricing logic, and pricing logic belongs on the domain entity or in a service that the domain owns. IsValid is validation, and validation belongs in a validator that runs at the boundary. The DTO itself should be reduced to the shape that crosses the wire:


// GOOD: only the data that crosses the boundary
public record OrderDto(
    int Id,
    string OrderNumber,
    IReadOnlyList<OrderLineDto> Lines,
    decimal DiscountPercent,
    decimal Total);           // computed once, on the side that owns the rule

    

Note that Total is now a plain value. The server computes it with the real pricing rules and ships the result. The client displays it. Neither side has to agree on how the number was produced.

Rule 2 A DTO needs no encapsulation

Domain entities protect their state. Their setters are private, invariants are enforced in the constructor and in explicit methods, and the only way to change an entity is through the operations it exposes. A DTO has nothing to protect. There is no invariant, because there is no behavior that depends on one.

  • Every property is public.
  • No private or protected members. They cannot be serialized and they have no reason to exist.
  • No constructor that does work. A constructor that only assigns parameters to properties is fine, and that is what a positional record gives you for free.

Immutable or mutable

Immutability is a separate question from encapsulation. An init-only type or a positional record is a good default for incoming messages, because it prevents a handler from accidentally modifying a request halfway through processing it. For outgoing objects with many properties that are populated in several steps, classic setters are often more practical than threading twenty arguments through a constructor.


// Incoming: immutable, built in one place, never changed afterwards
public record CreateUserRequest(string Email, string Password, string DisplayName);

// Outgoing report populated in several steps: setters are the pragmatic choice
public class MonthlySalesReport
{
    public int Year { get; set; }
    public int Month { get; set; }
    public decimal GrossRevenue { get; set; }
    public decimal Refunds { get; set; }
    public int NewCustomers { get; set; }
    public List<RegionBreakdown> Regions { get; set; } = new();
    // ... fifteen more properties filled by different query methods
}

// Middle ground (C# 11): compile-time "must be set", still no constructor
public class UpdateProfileRequest
{
    public required string DisplayName { get; init; }
    public string? Bio { get; init; }
}

    

Pick per type, based on how the object is built and consumed. There is no rule that says every DTO in a codebase must use the same shape.

Rule 3 Always properties, never fields

Once engineers hear "a DTO needs no encapsulation", a tempting shortcut appears: skip the { get; set; } and use public fields. It compiles, it reads cleanly, and it breaks serialization.


// BAD: public fields
public class OrderDto
{
    public int Id;
    public string OrderNumber;
    public decimal Total;
}

var dto = new OrderDto { Id = 42, OrderNumber = "SO-2026-0091", Total = 149.90m };
Console.WriteLine(JsonSerializer.Serialize(dto));

    
{}

System.Text.Json ignores fields by default. It reflects over public properties, finds none, and emits an empty object. No exception, no warning, just a payload with no data in it. The same DTO with properties works as expected:


// GOOD: properties
public record OrderDto(int Id, string OrderNumber, decimal Total);

    
{"Id":42,"OrderNumber":"SO-2026-0091","Total":149.90}
Why this bug tends to appear after a migration: Newtonsoft.Json serializes public fields by default, so a field-based DTO works for years on a Json.NET project and then starts returning {} the day the team switches to System.Text.Json. JsonSerializerOptions.IncludeFields = true exists as an escape hatch, but MVC model binding, EF Core projections and most mapping libraries are built around properties as well. Fixing the DTO is cheaper than configuring every consumer around it.

Rule 4 Use the Dto suffix only as a last resort

The suffix is fine for a general-purpose object with no specific role in the application, such as CustomerDto or AddressDto. It becomes noise the moment the type already has a name that describes its role.

Avoid Use Why
CreateUserRequestDto CreateUserRequest A request type is a DTO by definition
UserViewModelDto UserViewModel An MVC view model is a DTO by definition
UserCreatedEventDto UserCreatedEvent A message type is a DTO by definition
GetOrdersQueryResultDto OrderSummary The result of a query is a DTO by definition

Request, Response, ViewModel, Command, Query, Event: each of these words already tells the reader that the type carries data across a boundary. Adding Dto on top says the same thing twice and makes every file name longer.

Rule 5 API contracts, view models and messages are all DTOs, and they stay separate

Different layers give their transfer objects different names, but they are all the same kind of thing:

  • API request and response types.
  • MVC and Razor Pages view models. WPF and MVVM view models are the exception: they hold UI state, commands and change notification, which is behavior, so they are not DTOs.
  • Database query results, such as Dapper rows or EF Core projections into a plain type.
  • Messaging types: commands, queries and events in a CQRS or message-bus setup.

The trap here is the opposite of duplication. Because these types look alike, teams reuse one of them everywhere: the HTTP request becomes the command, the command becomes the event, and the event is what the API returns. It saves four class declarations and couples four boundaries to each other. A field renamed for API versioning now breaks a message consumer in another service that never called the API.

A user registration flow, done with one type per boundary:


// 1. Crosses the HTTP boundary. Shape is part of the public API contract.
public record CreateUserRequest(string Email, string Password, string DisplayName);

// 2. Crosses into the application layer. Can carry things the client never sends.
public record CreateUserCommand(string Email, string Password, string DisplayName, string TenantId);

// 3. Asked by the handler before it writes anything.
public record UserExistsQuery(string Email, string TenantId);

// 4. Published after the save. Carries facts that only exist after the save.
public record UserCreatedEvent(Guid UserId, string Email, string TenantId, DateTime CreatedUtc);

// 5. Crosses back over HTTP. Exposes exactly what the client needs and nothing more.
public record CreateUserResponse(Guid UserId);

    

[HttpPost("/api/users")]
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
{
    var command = new CreateUserCommand(
        request.Email, request.Password, request.DisplayName, TenantId: User.TenantId());

    var response = await _mediator.Send(command, ct);   // handler runs UserExistsQuery,
                                                        // saves, publishes UserCreatedEvent
    return CreatedAtAction(nameof(GetById), new { id = response.UserId }, response);
}

    

Five small records instead of one shared class. Each one can change on its own schedule. The password never reaches the event, the tenant id never has to be trusted from the client, and the response does not leak internal fields just because the entity has them.

Bonus Trap Validation attributes on positional records

This one catches people who follow every rule above. Positional records are a clean way to declare an immutable request DTO, and DataAnnotations are the built-in way to validate one. Combine them and the validator quietly stops working.


public record CreateUserRequest(
    [EmailAddress] string Email,
    [MinLength(8)] string Password);

var request = new CreateUserRequest("not-an-email", "123");
var results = new List<ValidationResult>();

var isValid = Validator.TryValidateObject(
    request, new ValidationContext(request), results, validateAllProperties: true);

Console.WriteLine($"isValid = {isValid}, errors = {results.Count}");

    
isValid = True, errors = 0

Both values are invalid and the validator reports none of it. The reason is where the compiler puts the attribute. In a positional record, [EmailAddress] string Email attaches the attribute to the constructor parameter. The compiler generates an Email property from that parameter, but it does not copy the attribute onto it. Validator.TryValidateObject only inspects properties, finds no attributes, and returns true.

Why it works in the controller and fails everywhere else: ASP.NET Core MVC model binding has understood record constructor parameters since .NET 5, so ModelState in a controller action reports the errors correctly. The same DTO validated by hand, in a message handler, a background job, or a unit test that calls Validator directly, passes invalid data through. The behavior depends on who is asking, which is the worst kind of bug to find in production.

Fix 1: target the property explicitly

C# lets you choose the attribute target on a positional parameter. The property: prefix moves the attribute onto the generated property, where every validator can see it:


public record CreateUserRequest(
    [property: EmailAddress] string Email,
    [property: MinLength(8)] string Password);

    
isValid = False, errors = 2

This is the minimal change and it is enough for simple rules. The downside is that it is easy to forget on the next record, and a forgotten prefix produces no compiler warning.

Fix 2: FluentValidation

For anything beyond a couple of attributes, FluentValidation keeps the rules out of the DTO entirely (which also satisfies Rule 1) and behaves the same whether the DTO is a class, a record, positional or not:


public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserRequestValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Password).MinimumLength(8);
        RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
    }
}

// in the handler, or through the FluentValidation ASP.NET Core integration
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid)
    return TypedResults.ValidationProblem(validation.ToDictionary());

    

The validator reads properties through lambdas, so the record versus class question does not exist for it. Cross-field rules, conditional rules and async checks (such as "email not already registered") all live in the same place, and the DTO stays a plain container.

Summary The five rules on one screen

Rule Do Avoid
1. No behavior Properties only, computed values shipped as data Methods, calculations, IsValid()
2. No encapsulation Everything public, init or set as fits the use Private members, constructors with logic
3. Properties, not fields { get; set; } or positional records Public fields that serialize to {}
4. Suffix last CreateUserRequest, UserCreatedEvent CreateUserRequestDto
5. One type per boundary Request, command, event and response as separate records One class reused across HTTP, mediator and bus
Bonus [property:] target or FluentValidation Bare DataAnnotations on positional record parameters

None of these rules is complicated, and that is the point. DTOs are the most numerous types in a typical .NET solution, so small decisions about them get multiplied by hundreds of files. Keep them dumb, keep them public, keep them as properties, name them by role, and validate them from the outside. The serialization and validation bugs described above simply stop happening.

These rules cover how a DTO should look. Whether a given DTO should exist at all is a separate question, and it is the subject of Part 2: When a DTO Is Worth Writing: Composition, Boundaries and CQRS with MediatR.


More posts
← All posts  ·  RSS © 2026 DotNET Leet