6 reglas para escribir DTOs

Qué es un DTO, qué se puede modelar como uno, por qué record le gana a class acá, y las reglas que uso para no terminar reutilizando el mismo DTO para todo.

Un DTO (Data Transfer Object) es un objeto cuyo único propósito es transportar datos entre capas o procesos. Nada más.

El problema no es la definición — es lo fácil que es romperla. Un DTO que empieza simple termina con lógica de negocio adentro, o reutilizado en tres lugares que en realidad necesitaban formas distintas. Estas son las reglas que uso para que eso no pase.

Inspirado en 5 Rules for DTOs, de Steve Smith (Ardalis) — acá sumé una regla más y lo reescribí a mi manera.


#1. Sin lógica ni comportamiento

No deben contener métodos que implementen reglas de negocio. Si un DTO tiene un método que decide algo, dejó de ser un DTO — se convirtió en otra cosa con un nombre engañoso.


#2. No deben forzar encapsulamiento

No obligues a usar setters/getters complejos; propiedades simples son suficientes. Un DTO no protege invariantes de dominio — solo transporta datos. Esa responsabilidad es de otra capa.


#3. Deben usar propiedades

Siempre propiedades públicas, preferiblemente auto-implementadas.

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

#4. Sin sufijo "DTO" o "Dto"

El nombre debe ser claro por el contexto, no por el sufijo. Evitá terminar un DTO simplemente en *Dto — sabemos que es un DTO, lo que no dice es para qué. Preferí *Request, *Response, *Event, *Message, según el rol que cumple. Ese único cambio evita que termines reutilizando el mismo DTO genérico para entrada, salida y evento a la vez.

(Escribí sobre esto con más detalle en Coding Conventions.)


#5. Qué se puede modelar como DTO

  • API Request o Response
  • Resultados de base de datos
  • Mensajes — comandos, consultas, eventos
  • View Models de MVC

#6. Inmutables

Una vez creado, sus valores no deben cambiar.

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

Con record, la inmutabilidad viene gratis — no hace falta escribirla a mano.


#Bonus: record vs class

Para DTOs, record es más compacto y simple de leer que la clase equivalente.

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);

El equivalente de CreateItemRequest escrito como clase:

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

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

Mismo resultado, mucho más código para llegar ahí.

Hay otra diferencia importante que no se ve en el código de arriba: la igualdad. Una class compara por referencia — dos instancias son iguales solo si son el mismo objeto en memoria, salvo que sobrescribas Equals/GetHashCode. Un record compara por valor, automáticamente: dos instancias son iguales si sus propiedades tienen los mismos valores.

CSHARP
var a = new CreateItemRequest("Título", "Desc");
var b = new CreateItemRequest("Título", "Desc");

a == b; // true en record, false en class (sin overrides)

Esto no es solo una curiosidad — es exactamente lo que hace que record sea una base natural para Value Objects: objetos que se definen por su valor, no por su identidad.


#Un séptimo punto: versionado

Después de publicar esto, un arquitecto me hizo un comentario que me pareció válido y quiero sumar: los DTOs — sobre todo los de Request/Response de una API pública — también necesitan poder versionarse.

Un DTO no vive aislado de la evolución de tu API. Si CreateUserRequest cambia de forma incompatible (un campo que pasa a ser obligatorio, uno que se elimina), y ya tenés clientes integrados contra la versión anterior, necesitás una estrategia: sufijar por versión (CreateUserRequestV2), versionar por ruta y mantener DTOs separados por versión, o diseñar pensando en evolución aditiva (campos nuevos opcionales, nunca romper los existentes).

No contradice las reglas anteriores — las complementa. Un DTO sigue siendo inmutable y sin lógica; el versionado es sobre cómo lo hacés evolucionar en el tiempo sin romper a quien ya lo consume.

#dto #csharp #api-design