11 KiB
Guía de diseño de DTOs para el ERP
1. Principios generales
- Un DTO representa únicamente el contrato de transporte entre cliente y servidor.
- Cada endpoint debe tener sus DTOs de
requestyresponse, aunque internamente reutilice subesquemas. - No reutilizar el mismo DTO entre endpoints salvo que el contrato sea realmente idéntico y estable.
- Siempre que sea posible, definir DTOs a partir de esquemas Zod v4 e inferir el tipo desde el esquema.
export type ListIssuedInvoicesRequestDTO = z.infer<typeof ListIssuedInvoicesRequestSchema>;
- El esquema valida; no debe “arreglar” entradas. Evitar
.default(),.transform()y similares salvo excepción justificada. - No mezclar DTOs con lógica de negocio, mapping ni estado de formulario.
2. Organización y naming
common/dto/
request/
create-proforma.request.dto.ts
get-proforma-by-id.request.dto.ts
list-proformas.request.dto.ts
update-proforma-by-id.request.dto.ts
...
response/
create-proforma.response.dto.ts
get-proforma-by-id.response.dto.ts
list-proformas.response.dto.ts
update-proforma-by-id.response.dto.ts
...
shared/
proforma-summary.dto.ts
proforma-detail.dto.ts
proforma-item.dto.ts
proforma-tax.dto.ts
recipient.dto.ts
- Estructurar DTOs por módulo en
request/,response/yshared/. shared/solo para subesquemas reutilizables y estables.- Nombres de fichero en
kebab-case. - Patrón de nombres:
<action>-<resource>[...].request.dto.ts
<action>-<resource>[...].response.dto.ts
-
Usar singular/plural de forma consistente:
- colección -> plural
- elemento -> singular
Ejemplos:
list-proformas.request.dto.ts
get-proforma-by-id.response.dto.ts
update-proforma-by-id.request.dto.ts
<resource>-summary.dto.tspara vistas de resumen reutilizables de list.<resource>-detail.dto.tspara vistas de detalle reutilizables de get.<action>-<resource>.response.dto.tspara la response completa del endpoint.<resource>-summary.snapshot-builder.tspara builders que materializan un item/resumen.- Evitar nombres como
I<Resource>Snapshotsi representan el mismo contrato que un DTO ya definido por schema.
3. Reglas de tipado
-
No usar
z.string()si el tipo semántico del campo puede concretarse mejor. -
El mismo campo debe conservar siempre el mismo nombre y el mismo tipo en todos los DTOs.
-
Ejemplos de tipado semántico:
- booleano ->
z.boolean() - UUID ->
z.uuid() - conjunto cerrado ->
z.enum([...]) - fecha/timestamp ISO -> schema específico
- arrays ->
z.array(...) - objetos ->
z.object(...)
- booleano ->
4. Reglas para primitivas de negocio
- Si un identificador es UUID, usar siempre
z.uuid(). - Si un identificador no es UUID, definir un schema específico con nombre explícito.
- Todo estado de negocio cerrado debe modelarse con
z.enum(...). - No usar
z.string()para booleanos ni para estados cerrados. - Distinguir explícitamente entre fecha, hora y timestamp mediante schemas específicos.
5. Fechas, porcentajes, importes, cantidades
5.1. Amount, Money, Percentage y Quantity
- Los valores exactos no deben viajar como
number. - Deben viajar siempre con una única forma de transporte para toda la API.
type MoneyDTO = {
value: string;
scale: string;
currency_code: string;
};
type AmountDTO = {
value: string;
scale: string;
};
type PercentageDTO = {
value: string;
scale: string;
}
type QuantityDTO = {
value: string;
scale: string;
}
5.2. Fechas y timestamps
Distinguir entre:
- fecha de calendario sin hora -> "2020-01-01"
- timestamp con fecha y hora ->
export const IsoDateSchema = z.iso.date(); // "2020-01-01"
export const IsoTimeSchema = z.iso.time(); // "03:15:00", "03:15"
export const OffsetDateTimeSchema = z.iso.datetime({ offset: true }); // "2020-01-01T06:15:00+02:00"
export const LocalDateTimeSchema = z.iso.datetime({ local: true }); // "2020-01-01T06:15:01"
export const DateTimeSchema1 = z.iso.datetime({ precision: -1 }); // minute precision (no seconds) -> "2020-01-01T06:15Z"
Si se prefiere no usar z.iso.date(), crear schema propio.
6. Opcionalidad, nullabilidad y ausencia
Significados:
undefined/ campo omitido -> no enviadonull-> enviado explícitamente sin valor""-> cadena vacía, no indica ausencia de ese campo[]-> colección vacía, no indica ausencia de esa colección
No cambiar estos significados salvo decisión explícita de contrato.
6.1. Para requests
- Usar
optional()para campos no obligatorios. - Usar
nullable()solo cuando tenga semántica de negocio, por ejemplo “borrar” o “desvincular”.
Ejemplo:
payment_method_id: z.uuid().nullable().optional()
Interpretación:
- omitido -> no tocar
null-> quitar relación- UUID -> asignar relación
6.2. Para responses
- Decidir de forma explícita si una ausencia semántica se representa con
nullo con omisión. - Para relaciones opcionales, suele ser más claro usar
nullque omitir la relación.
7. Reglas para CREATE
- El request de
createdebe contener solo los datos necesarios para crear el recurso. El resto, como opcionales =>optional() - No reutilizar el DTO de detalle como DTO de creación.
- No usar defaults en requests de negocio.
- Si hace falta normalización, hacerla fuera del DTO:
validación DTO => normalización input => mapeo a command/props
8. Reglas para UPDATE
-
updatedebe seguir semántica tipoPATCH:- solo viajan los campos a modificar
- los campos omitidos no se tocan
-
La opcionalidad debe declararse en el esquema con
optional(). -
No usar
Partial<>sobre tipos inferidos si el esquema ya expresa opcionalidad. -
No usar defaults en
update, especialmente en colecciones.
Incorrecto:
items: z.array(...).default([])
Correcto:
items: z.array(...).optional()
8.1. UPDATE de colecciones
- Por defecto, mantener reemplazo completo de la colección.
- Si se necesita granularidad real, usar una estructura explícita:
items: {
create: [...],
update: [...],
delete: [...],
}
9. Responses de LIST y DETAIL
-
listygetno deben compartir el mismo esquema completo. -
Separar vistas de resumen y detalle:
List-> summaryGet-> detail
-
listdebe contener solo lo necesario para el listado. -
Puede existir un esquema base compartido solo si mejora claridad y estabilidad.
10. Subesquemas reutilizables
- Extraer a
shared/toda estructura no trivial que se repita y sea estable. - No extraer abstracciones prematuramente.
Ejemplos típicos:
recipientitemtax-breakdownpayment-method-ref
10.bis. Snapshot builders y materialización de DTOs
- Los snapshot builders de API existen para materializar objetos de salida serializables a partir de modelos de aplicación, agregados o read models.
- Un snapshot builder no define el contrato HTTP; el contrato lo define el schema DTO.
- El schema Zod es la única fuente de verdad (SSOT) del contrato de salida.
Regla principal
Si un snapshot builder genera un DTO de API, su tipo de salida debe ser el
z.inferdel schema exacto que representa.
Ejemplo:
export const CustomerSummarySchema = z.object({ ... });
export type CustomerSummaryDTO = z.infer<typeof CustomerSummarySchema>;
export class CustomerSummarySnapshotBuilder
implements ISnapshotBuilder<CustomerSummary, CustomerSummaryDTO> {
public toOutput(model: CustomerSummary): CustomerSummaryDTO {
...
}
}
Reglas obligatorias
- No duplicar el contrato mediante interfaces manuales paralelas si ya existe un schema Zod equivalente.
- Eliminar tipos como
ICustomerSummarySnapshotsi representan el mismo contrato que un DTO. - Si el builder construye una subvista reutilizable, debe tiparse contra el subschema compartido correspondiente (
shared/). - Si el builder construye la response completa, debe tiparse contra el response DTO completo.
- El builder debe devolver exactamente la forma del schema (no “aproximada”).
Responsabilidades del snapshot builder
Puede:
- Mapear
Maybe→null - Convertir Value Objects → primitives o DTOs (
string,{ value, scale }, etc.) - Adaptar enums internos → valores de contrato
- Construir vistas (
summary,detail, etc.)
No puede:
- Definir contratos
- Validar reglas de negocio
- Acceder a repositorios
- Ejecutar lógica de dominio
- “corregir” datos fuera del contrato definido
Separación por niveles
Si el endpoint es:
ListCustomersResponseSchema
Entonces:
- Subschema:
CustomerSummarySchema
- Builder:
CustomerSummarySnapshotBuilder -> CustomerSummaryDTO
No:
Builder -> ListCustomersResponseDTO ❌
Naming obligatorio
DTO:
<resource>-summary.dto.ts
<resource>-detail.dto.ts
<action>-<resource>.response.dto.ts
Builders:
<resource>-summary.snapshot-builder.ts
<resource>-detail.snapshot-builder.ts
<action>-<resource>.response.snapshot-builder.ts
Evitar:
I<Resource>Snapshot ❌
<Resource>Output ❌
<Resource>Interface ❌
Regla de consistencia
Si cambia el schema DTO, el builder debe romper en compile-time.
Esto garantiza:
- contrato consistente
- ausencia de drift
- eliminación de duplicación
11. Errores
- El DTO de error debe ser estable, tipado y predecible.
- No usar shapes excesivamente genéricos si ya se conoce la estructura mínima útil.
- El cliente debe poder representar errores sin heurísticas frágiles.
12. Metadata
metadatasolo debe contener datos auxiliares, trazabilidad no crítica, versionado o hints no funcionales.- Si un dato es necesario para que el cliente funcione, no debe ir en
metadata, sino en el DTO principal.
13. Evolución de la API
- Es mejor añadir campos que reinterpretar campos existentes.
- Cambiar el significado o la estructura de un campo implica cambio de contrato.
14. Criterio para detectar overengineering
Un diseño de DTO está sobreingenierizado si:
- para entender un endpoint simple hay que abrir demasiados ficheros
- el esquema valida, normaliza y transforma a la vez
- se extraen subesquemas que apenas se reutilizan
- el DTO intenta reflejar demasiado fielmente el dominio interno
Regla práctica:
- preferir duplicación pequeña y clara frente a abstracción prematura
15. Checklist de revisión
Antes de cerrar un DTO, comprobar:
- ¿Representa solo contrato de transporte?
- ¿Cada campo usa el tipo semántico más preciso posible?
- ¿Se distingue bien entre omitido,
null, vacío y colección vacía? - ¿Hay defaults ocultando intención?
- ¿El naming es consistente?
- ¿Las estructuras repetidas merecen extraerse?
- ¿Hay abstracciones prematuras?
- ¿El cliente puede consumirlo sin heurísticas frágiles?
- ¿El mismo campo mantiene el mismo tipo en todo el módulo?
- ¿El DTO refleja una vista concreta del endpoint?