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
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
privateorprotectedmembers. 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);
{} 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}");
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.
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);
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.
- When a DTO Is Worth Writing: Composition, Boundaries and CQRS with MediatR
- Start With a Loosely Coupled Monolith
- Concurrency and Locking in .NET: The Series