6 Rules for Writing DTOs

What a DTO is, what can be modeled as one, why record beats class here, and the rules I use so I don't end up reusing the same DTO for everything.

A DTO (Data Transfer Object) is an object whose only purpose is carrying data between layers or processes. Nothing else.

The definition isn't the problem — how easy it is to break is. A DTO that starts simple ends up with business logic inside, or reused in three places that actually needed different shapes. These are the rules I use to keep that from happening.

Inspired by 5 Rules for DTOs, by Steve Smith (Ardalis) — I added one more rule here and rewrote it in my own words.


#1. No logic or behavior

They shouldn't contain methods that implement business rules. If a DTO has a method that decides something, it stopped being a DTO — it became something else with a misleading name.


#2. Don't force encapsulation

Don't require complex setters/getters; simple properties are enough. A DTO doesn't protect domain invariants — it just carries data. That responsibility belongs to another layer.


#3. Use properties

Always public properties, preferably auto-implemented.

CSHARP
public record CreateItemRequest(string Title, string Description);

#4. No "DTO" or "Dto" suffix

The name should be clear from context, not from the suffix. Avoid ending a DTO in a plain *Dto — we already know it's a DTO, what it doesn't say is what for. Prefer *Request, *Response, *Event, *Message, depending on the role it plays. That one change alone stops you from reusing the same generic DTO for input, output, and events all at once.

(I wrote about this in more detail in Coding Conventions.)


#5. What can be modeled as a DTO

  • API Request or Response
  • Database query results
  • Messages — commands, queries, events
  • MVC View Models

#6. Immutable

Once created, its values shouldn't change.

CSHARP
public record CreateItemResponse(Guid Id, string Title, string Description);

With record, immutability comes for free — no need to write it by hand.


#Bonus: record vs class

For DTOs, record is more compact and easier to read than the equivalent class.

CSHARP
// Requests
public record CreateItemRequest(string Title, string Description);

// Response
public record CreateItemResponse(Guid Id, string Title, string Description);

// Command
public record CreateItemCommand(string Title, string Description);

// Query
public record GetItemByTitleQuery(string Title);

// Event
public record ItemCreatedEvent<T>(Guid Id, DateOnly OccurredOn, T data);

The equivalent of CreateItemRequest written as a class:

CSHARP
public class CreateItemRequest
{
    public string Title { get; init; }
    public string Description { get; init; }

    public CreateItemRequest(string title, string description)
    {
        Title = title;
        Description = description;
    }
}

Same result, a lot more code to get there.

There's another important difference not visible in the code above: equality. A class compares by reference — two instances are equal only if they're the same object in memory, unless you override Equals/GetHashCode. A record compares by value, automatically: two instances are equal if their properties hold the same values.

CSHARP
var a = new CreateItemRequest("Title", "Desc");
var b = new CreateItemRequest("Title", "Desc");

a == b; // true for record, false for class (without overrides)

This isn't just a curiosity — it's exactly what makes record a natural fit for Value Objects: objects defined by their value, not their identity.


#A seventh point: versioning

After publishing this, an architect made a comment I thought was worth adding: DTOs — especially Request/Response ones on a public API — also need to be versionable.

A DTO doesn't live in isolation from your API's evolution. If CreateUserRequest changes in a breaking way (a field that becomes required, one that gets removed), and you already have clients integrated against the previous version, you need a strategy: suffix by version (CreateUserRequestV2), version by route and keep separate DTOs per version, or design for additive evolution from the start (new fields optional, never breaking existing ones).

It doesn't contradict the rules above — it complements them. A DTO is still immutable and logic-free; versioning is about how you evolve it over time without breaking whoever already consumes it.

#dto #csharp #api-design