DTOS - GUÍA DE DISEÑO.md

This commit is contained in:
David Arranz 2026-04-16 19:21:05 +02:00
parent 7555d3f1ff
commit 1ce77814b5

View File

@ -0,0 +1,561 @@
## Guía de diseño de DTOs para el ERP
---
* Un DTO debe representar **únicamente** el **contrato de transporte** entre procesos.
* DTOs request/response separados por endpoint, aunque internamente reutilice piezas.
* No reutilizar el mismo DTO para varios endpoints salvo que el contrato sea realmente idéntico y estable.
* Sin `defaults` en requests de negocio. Deben evitarse salvo casos muy concretos y no ambiguos.
* En la medida de lo posible, usar esquemas Zod v4 para luego inferir el tipo del DTO:
Ejemplo:
```ts
export type ListIssuedInvoicesRequestDTO = z.infer<typeof ListIssuedInvoicesRequestSchema>;
```
## 1. Esquemas Zod y DTOs
* **El esquema valida, no “arregla” entradas**. Evitar `.default()`, `.transform()` y similares salvo excepción muy justificada.
* Las operaciones `list`, `get`, `create`, `update` y `report` no deben compartir el mismo esquema completo pero sí deben compartir `naming`, reglas de tipos, convenciones y reutilizar subesquemas comunes.
* No usar `z.string()` si se puede precisar más el tipo semántico. El schema debe expresar lo que **es** el dato, no solo cómo serializarlo.
Ejemplos:
- booleano -> `z.boolean()`
- UUID -> `z.uuid()`
- estado cerrado -> `z.enum([...])`
- fecha ISO -> schema específico
- código ISO -> schema específico o restricción mínima consistente
* Conservar el mismo tipo para el mismo campo en todos los esquemas. Si `customer_id` es UUID, debe ser UUID siempre.
* Conservar el mismo nombre para campos con la misma semántica a no ser que esté justificado.
Ejeemplo. No alternar:
- `recipient_id`
- `customer_id`
- `recipient_customer_id`
---
## 2. Estructura recomendada de DTOs
## 2.1. Organización por módulo
* Agrupar endpoints en `request/` y `response/`
* `shared/` para fragmentos reutilizables de shape
* no meter lógica de mapping en `dto/`
```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
```
---
## 3. Convenciones de naming
## 3.1. Ficheros
* Siempre en `kebab-case`.
* Uso de singular/plural: colección -> plural (`proformas`), elemento -> singular (`proforma`)
* Patrón:
```plaintext
<action>-<resource>[...].request.dto.ts
<action>-<resource>[...].response.dto.ts
```
Ejemplos:
* `list-proformas.request.dto.ts`
* `get-proforma-by-id.response.dto.ts`
* `update-proforma-by-id.request.dto.ts`
---
## 4. Reglas de tipos serializados
## 4.1. Cuándo usar string
Usar `string` cuando el dato de transporte sea realmente textual o cuando convenga preservar exactitud/neutralidad:
* ids externos textuales
* códigos
* fechas ISO serializadas
* importes
* cantidades
* porcentajes
* referencias documentales
* descripciones
* notas
## 4.2. Cuándo no usar string
No serializar como string si el tipo semántico natural debe conservarse y no hay ventaja en degradarlo.
Ejemplos:
* booleanos -> `z.boolean()`
* enteros de paginación -> `z.number().int()`
* arrays -> `z.array(...)`
* objetos -> `z.object(...)`
---
## 5. Reglas para primitivas de negocio
* Un VO monetario/cuantitativo siempre debe tener una sola forma de transporte en todo el ERP.
## 5.1. Identificadores
* Si el identificador es UUID, usar `z.uuid()`.
* Si existe un id no UUID, definir un schema específico con nombre explícito.
* No mezclar `z.string()` y `z.uuid()` para el mismo campo según endpoint.
* Un mismo identificador debe tener el mismo schema en todos los DTOs.
Ejemplo:
```ts
proforma_id: z.uuid()
customer_id: z.uuid()
company_id: z.uuid()
```
---
## 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 `z.iso.date()` no está disponible o no os convence, crear schema propio.
---
## 5.3. Enums y estados
* Todo campo de conjunto cerrado debe ser `z.enum(...)`. Ejemplos:
```
* `status`
* `language_code` si el conjunto es acotado
* `currency_code` si estáis acotando monedas soportadas
```
* No usar `z.string()` para estados de negocio cerrados.
* Si el conjunto no está cerrado aún, al menos documentarlo y preparar schema específico.
Ejemplo:
```ts
export const ProformaStatusSchema = z.enum([
"draft",
"pending",
"issued",
"cancelled",
]);
```
---
## 5.4. Booleanos
Ejemplo:
```ts
z.boolean()
```
---
## 5.5. Amount, Money, Percentage y Quantity
* Estos valores deben viajar como objetos explícitos:
```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.6. Listas
```ts
type ListIssuedInvoicesResponseDTO = {
page: number,
per_page: number,
total_pages: number,
total_items: number,
items: {
...
}
}
```
---
## 6. Nullabilidad, opcionalidad y semántica de ausencia
Significado:
- `undefined` / campo omitido: no enviado
- `null`: enviado explícitamente sin valor
- `""`: cadena vacía, no ausencia
- `[]`: colección vacía, no ausencia
**No cambiar estos significados salvo decisión explícita de contrato.**
### 6.1. Para `requests`
* Preferir `optional()` para campos no obligatorios.
* Usar `nullable()` solo cuando tenga significado de negocio “borrar/desvincular”.
Ejemplo:
```ts
payment_method_id: z.uuid().nullable().optional()
Interpretación:
* omitido -> no tocar
* `null` -> quitar método de pago
* UUID -> asignar id de método de pago
```
### 6.3. Para `responses`
* si el campo puede no existir semánticamente, decidir si se usa `null` o ausencia `""`
* para relaciones opcionales, suele ser más claro usar `null` que omitir la relación.
---
## 7. Reglas para requests de CREATE
* Definir como obligatorios en el esquema los campos necesarios para crear el recurso. El resto, como opcionales => `optional()`
* No reutilizar el DTO de detalle como DTO de creación.
* No usar defaults
Incorrecto:
```ts
description: z.string().default("")
```
Correcto:
```ts
description: z.string().optional()
```
* Si el backend necesita normalización, hacerla fuera del DTO. Secuencia: `validación DTO` => `normalización input` => ' '
---
## 8. Reglas para requests de UPDATE
* Usar semántica tipo **PATCH**:
- solo viajan los campos a modificar
- los campos omitidos no se tocan
* Si un campo es opcional, usar `optional()` en el esquema:
```ts
series: z.string().optional()
```
* No usar `defaults` en update:
Esto es incorrecto porque `[]` puede significar “vaciar items”, no “campo omitido”.
```ts
items: z.array(...).default([])
```
Correcto:
```ts
items: z.array(...).optional()
```
* no usar `Partial<>`:
Incorrecto:
```ts
type UpdateProformaByIdRequestDTO = Partial<z.infer<typeof UpdateProformaByIdRequestSchema>>;
```
## 8.1. Reglas para request de UPDATE en **colecciones**
* En `update` de colecciones, mantener reemplazo completo de la colección
* Si se necesitara granularidad real en el `update` de items, valorar usar en la API una estructura explícita:
```ts
items: {
create: [...]
update: [...]
delete: [...]
}
```
---
## 11. Reglas para responses de **LIST** y **DETAIL**
* Separar summary y detail:
- List => summary => `ProformaSummaryDTO`
- Get <=> detail => `ProformaDetailDTO`
* `list` debe contener solo lo imprescindible para la tabla/listado
* Puede definirse un esquema base compartido **si aporta claridad**. Ejemplo:
```ts
const ProformaBaseDTOSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
customer_id: z.uuid(),
invoice_number: z.string(),
status: ProformaStatusSchema,
series: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema,
language_code: z.string(),
currency_code: z.string(),
reference: z.string().nullable(),
description: z.string().nullable(),
});
```
Despues:
* `summary` extiende añadiendo algún campos más
* `detail` extiende añadiendo taxes, items, notes, etc.
---
## 12. Reglas para subesquemas reutilizables
* Estructura no trivial que se repite => definir subesquema en `shared/`.
* Deben ser estructuras estables no sujetas a cambios. En caso contrario, mejor no reutilizar
Ejemplos claros en tu caso:
* `recipient`
* `proforma-item`
* `proforma-tax-breakdown`
* `payment-method-ref`
---
## 13. Reglas específicas para errores
* El DTO de error debe ser estable, tipado y predecible.
## 13.1. Estructura base recomendada
```ts
export const ErrorDetailDTOSchema = z.object({
code: z.string(),
path: z.string().optional(),
message: z.string(),
});
export const ErrorResponseDTOSchema = z.object({
type: z.string().optional(),
title: z.string(),
status: z.number().int(),
detail: z.string().optional(),
instance: z.string().optional(),
context: z
.object({
params: z.record(z.string(), z.unknown()).optional(),
query: z.record(z.string(), z.unknown()).optional(),
body: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
extra: z
.object({
errors: z.array(ErrorDetailDTOSchema).default([]),
})
.optional(),
});
```
## 13.2. Regla
No dejar `errors: Record<string, unknown>[]` si ya conoces la forma mínima útil.
El cliente necesita poder renderizar errores sin heurísticas frágiles.
---
## 14. Reglas de metadata
* Si un consumidor necesita ese dato para funcionar, no va en `metadata`: va en el DTO principal.
## 14.1. Cuándo usarla
Solo para:
* datos auxiliares
* trazabilidad no crítica
* versionado
* hints no funcionales
## 14.2. Cuándo no usarla
No usar `metadata` para:
* reglas de negocio
* campos que condicionan UI principal
* datos que el cliente necesita interpretar de forma contractual
---
## 15. Evolución de la API
* Es mejor añadir un campo nuevo que cambiar el significado de uno existente.
* Si un campo cambia de semántica o estructura, debe tratarse como cambio de contrato.
## 16. Criterio para detectar `overengineering`
Considera que un diseño de DTO está `sobreingenierizado` si ocurre alguna de estas:
* necesitas abrir 5 ficheros para entender un endpoint simple
* el schema hace validación, normalización, defaults y transformación a la vez
* hacer abstracciones compartidas en `shared` que lueo solo se utilizan en un caso
* el DTO intenta reflejar demasiado fielmente el dominio interno
* preferir duplicación pequeña y clara frente a abstracción prematura
---
## 21. Plantilla base recomendada
## 21.1. Request simple por id
```ts
import { z } from "zod/v4";
export const GetProformaByIdRequestSchema = z.object({
proforma_id: z.uuid(),
});
export type GetProformaByIdRequestDTO = z.infer<typeof GetProformaByIdRequestSchema>;
```
## 21.2. Fragmento shared
```ts
import { z } from "zod/v4";
import { MoneyDTOSchema, PercentageDTOSchema } from "@erp/core";
export const ProformaTaxDTOSchema = z.object({
taxable_amount: MoneyDTOSchema,
iva_code: z.string(),
iva_percentage: PercentageDTOSchema,
iva_amount: MoneyDTOSchema,
rec_code: z.string(),
rec_percentage: PercentageDTOSchema,
rec_amount: MoneyDTOSchema,
retention_code: z.string(),
retention_percentage: PercentageDTOSchema,
retention_amount: MoneyDTOSchema,
taxes_amount: MoneyDTOSchema,
});
export type ProformaTaxDTO = z.infer<typeof ProformaTaxDTOSchema>;
```
## 21.3. Update parcial sin defaults
```ts
import { z } from "zod/v4";
export const UpdateProformaByIdRequestSchema = z.object({
series: z.string().optional(),
invoice_date: z.iso.date().optional(),
operation_date: z.iso.date().optional(),
customer_id: z.uuid().optional(),
reference: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
language_code: z.string().optional(),
currency_code: z.string().optional(),
items: z.array(UpdateProformaItemRequestSchema).optional(),
});
export type UpdateProformaByIdRequestDTO = z.infer<typeof UpdateProformaByIdRequestSchema>;
```
---
## 22. Checklist de revisión de un DTO
Antes de dar un DTO por bueno, comprobar:
1. ¿Representa contrato de transporte y nada más?
2. ¿Cada campo tiene 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 que estén ocultando intención?
5. ¿El naming es consistente con el resto?
6. ¿Hay subestructuras repetidas que merezcan extraerse?
7. ¿Hay abstracciones extraídas demasiado pronto?
8. ¿El cliente puede consumirlo sin heurísticas frágiles?
9. ¿El mismo campo mantiene mismo tipo en todo el módulo?
10. ¿El DTO refleja una vista del endpoint y no una mezcla de varias?
---