562 lines
13 KiB
Markdown
562 lines
13 KiB
Markdown
## 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?
|
|
|
|
---
|
|
|