DTOS - GUÍA DE DISEÑO.md
This commit is contained in:
parent
7555d3f1ff
commit
1ce77814b5
561
docs/DTOS - GUÍA DE DISEÑO.md
Normal file
561
docs/DTOS - GUÍA DE DISEÑO.md
Normal 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?
|
||||
|
||||
---
|
||||
|
||||
Loading…
Reference in New Issue
Block a user