412 lines
11 KiB
Markdown
412 lines
11 KiB
Markdown
# 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 `request` y `response`, 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.
|
|
|
|
```ts
|
|
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
|
|
|
|
```plaintext
|
|
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/` y `shared/`.
|
|
* `shared/` solo para subesquemas reutilizables y estables.
|
|
* Nombres de fichero en `kebab-case`.
|
|
* Patrón de nombres:
|
|
|
|
```plaintext
|
|
<action>-<resource>[...].request.dto.ts
|
|
<action>-<resource>[...].response.dto.ts
|
|
```
|
|
|
|
* Usar singular/plural de forma consistente:
|
|
|
|
* colección -> plural
|
|
* elemento -> singular
|
|
|
|
Ejemplos:
|
|
|
|
```plaintext
|
|
list-proformas.request.dto.ts
|
|
get-proforma-by-id.response.dto.ts
|
|
update-proforma-by-id.request.dto.ts
|
|
```
|
|
|
|
* `<resource>-summary.dto.ts` para vistas de resumen reutilizables de list.
|
|
* `<resource>-detail.dto.ts` para vistas de detalle reutilizables de get.
|
|
* `<action>-<resource>.response.dto.ts` para la response completa del endpoint.
|
|
* `<resource>-summary.snapshot-builder.ts` para builders que materializan un item/resumen.
|
|
* Evitar nombres como `I<Resource>Snapshot` si 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(...)`
|
|
|
|
## 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.
|
|
|
|
```ts
|
|
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 ->
|
|
|
|
```ts
|
|
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 enviado
|
|
* `null` -> 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:
|
|
|
|
```ts
|
|
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 `null` o con omisión.
|
|
* Para relaciones opcionales, suele ser más claro usar `null` que omitir la relación.
|
|
|
|
## 7. Reglas para CREATE
|
|
|
|
* El request de `create` debe 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:
|
|
|
|
```plaintext
|
|
validación DTO => normalización input => mapeo a command/props
|
|
```
|
|
|
|
## 8. Reglas para UPDATE
|
|
|
|
* `update` debe seguir semántica tipo `PATCH`:
|
|
|
|
* 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:
|
|
|
|
```ts
|
|
items: z.array(...).default([])
|
|
```
|
|
|
|
Correcto:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
items: {
|
|
create: [...],
|
|
update: [...],
|
|
delete: [...],
|
|
}
|
|
```
|
|
|
|
## 9. Responses de LIST y DETAIL
|
|
|
|
* `list` y `get` no deben compartir el mismo esquema completo.
|
|
* Separar vistas de resumen y detalle:
|
|
|
|
* `List` -> summary
|
|
* `Get` -> detail
|
|
* `list` debe 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:
|
|
|
|
* `recipient`
|
|
* `item`
|
|
* `tax-breakdown`
|
|
* `payment-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.infer` del schema exacto que representa.
|
|
|
|
Ejemplo:
|
|
|
|
```ts
|
|
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 `ICustomerSummarySnapshot` si 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:
|
|
|
|
```ts
|
|
ListCustomersResponseSchema
|
|
```
|
|
|
|
Entonces:
|
|
|
|
* Subschema:
|
|
|
|
```ts
|
|
CustomerSummarySchema
|
|
```
|
|
|
|
* Builder:
|
|
|
|
```ts
|
|
CustomerSummarySnapshotBuilder -> CustomerSummaryDTO
|
|
```
|
|
|
|
No:
|
|
|
|
```ts
|
|
Builder -> ListCustomersResponseDTO ❌
|
|
```
|
|
|
|
---
|
|
|
|
### Naming obligatorio
|
|
|
|
DTO:
|
|
|
|
```plaintext
|
|
<resource>-summary.dto.ts
|
|
<resource>-detail.dto.ts
|
|
<action>-<resource>.response.dto.ts
|
|
```
|
|
|
|
Builders:
|
|
|
|
```plaintext
|
|
<resource>-summary.snapshot-builder.ts
|
|
<resource>-detail.snapshot-builder.ts
|
|
<action>-<resource>.response.snapshot-builder.ts
|
|
```
|
|
|
|
Evitar:
|
|
|
|
```plaintext
|
|
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
|
|
|
|
* `metadata` solo 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:
|
|
|
|
1. ¿Representa solo contrato de transporte?
|
|
2. ¿Cada campo usa el tipo semántico más preciso posible?
|
|
3. ¿Se distingue bien entre omitido, `null`, vacío y colección vacía?
|
|
4. ¿Hay defaults ocultando intención?
|
|
5. ¿El naming es consistente?
|
|
6. ¿Las estructuras repetidas merecen extraerse?
|
|
7. ¿Hay abstracciones prematuras?
|
|
8. ¿El cliente puede consumirlo sin heurísticas frágiles?
|
|
9. ¿El mismo campo mantiene el mismo tipo en todo el módulo?
|
|
10. ¿El DTO refleja una vista concreta del endpoint?
|
|
|
|
--- |