13 KiB
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
defaultsen 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:
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,updateyreportno deben compartir el mismo esquema completo pero sí deben compartirnaming, 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_ides 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_idcustomer_idrecipient_customer_id
2. Estructura recomendada de DTOs
2.1. Organización por módulo
- Agrupar endpoints en
request/yresponse/ shared/para fragmentos reutilizables de shape- no meter lógica de mapping en
dto/
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:
<action>-<resource>[...].request.dto.ts
<action>-<resource>[...].response.dto.ts
Ejemplos:
list-proformas.request.dto.tsget-proforma-by-id.response.dto.tsupdate-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()yz.uuid()para el mismo campo según endpoint. - Un mismo identificador debe tener el mismo schema en todos los DTOs.
Ejemplo:
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 ->
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:
export const ProformaStatusSchema = z.enum([
"draft",
"pending",
"issued",
"cancelled",
]);
5.4. Booleanos
Ejemplo:
z.boolean()
5.5. Amount, Money, Percentage y Quantity
- Estos valores deben viajar como objetos explícitos:
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
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 enviadonull: 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:
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
nullo ausencia"" - para relaciones opcionales, suele ser más claro usar
nullque 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:
description: z.string().default("")
Correcto:
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:
series: z.string().optional()
- No usar
defaultsen update:
Esto es incorrecto porque [] puede significar “vaciar items”, no “campo omitido”.
items: z.array(...).default([])
Correcto:
items: z.array(...).optional()
- no usar
Partial<>:
Incorrecto:
type UpdateProformaByIdRequestDTO = Partial<z.infer<typeof UpdateProformaByIdRequestSchema>>;
8.1. Reglas para request de UPDATE en colecciones
- En
updatede colecciones, mantener reemplazo completo de la colección - Si se necesitara granularidad real en el
updatede items, valorar usar en la API una estructura explícita:
items: {
create: [...]
update: [...]
delete: [...]
}
11. Reglas para responses de LIST y DETAIL
-
Separar summary y detail:
- List => summary =>
ProformaSummaryDTO - Get <=> detail =>
ProformaDetailDTO
- List => summary =>
-
listdebe contener solo lo imprescindible para la tabla/listado -
Puede definirse un esquema base compartido si aporta claridad. Ejemplo:
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:
summaryextiende añadiendo algún campos másdetailextiende 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:
recipientproforma-itemproforma-tax-breakdownpayment-method-ref
13. Reglas específicas para errores
- El DTO de error debe ser estable, tipado y predecible.
13.1. Estructura base recomendada
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
sharedque 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
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
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
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:
- ¿Representa contrato de transporte y nada más?
- ¿Cada campo tiene el tipo semántico más preciso posible?
- ¿Se distingue bien entre omitido, null, vacío y colección vacía?
- ¿Hay defaults que estén ocultando intención?
- ¿El naming es consistente con el resto?
- ¿Hay subestructuras repetidas que merezcan extraerse?
- ¿Hay abstracciones extraídas demasiado pronto?
- ¿El cliente puede consumirlo sin heurísticas frágiles?
- ¿El mismo campo mantiene mismo tipo en todo el módulo?
- ¿El DTO refleja una vista del endpoint y no una mezcla de varias?