Seis reglas de DDD para estructurar dominios en aplicaciones NestJS
Aprenda seis reglas prácticas de diseño orientado a dominios para organizar los módulos, entidades y eventos de NestJS, de modo que las funcionalidades permanezcan aisladas y fáciles de mantener.
Después de medio año trabajando con la mayoría de los proyectos basados en NestJS, surge una fricción familiar. Agregas un único campo para soportar una función específica, y de repente una prueba en una parte completamente ajena a la aplicación falla. Alguien del equipo pregunta dónde se encuentra realmente la lógica de procesamiento, y la respuesta honesta es “está dispersa, un poco por todas partes”.
Esto no es señal de una ingeniería descuidada. Por lo general, indica que el código fue organizado en torno a capas técnicas en lugar de en función de los conceptos que representa. El Diseño Dirigido por Dominios en su forma completa es un conjunto complejo de prácticas que la mayoría de los equipos nunca adoptan por completo. Lo que sigue es un enfoque más sencillo: seis reglas que realmente generan beneficios dentro de una aplicación Nest, evitando las ceremonias innecesarias. Considéralo como DDD selectivo.
Regla 1: No hagas que un único modelo sirva a toda la aplicación
Casi todos los backends complicados tienen un objeto “Dios” en su centro. A menudo se le llama Order; cuenta con docenas de columnas opcionales y la mitad del código lo importa. Con el tiempo, un cambio hecho para el equipo de almacén rompe silenciosamente el proceso de pago.
Ese único Order en realidad representa tres responsabilidades distintas ocultas bajo un mismo nombre:
- Pago: se ocupa de los precios, descuentos y la intención de pago.
- Entrega: se ocupa de los códigos SKU y la dirección de envío, sin tener en cuenta los descuentos.
- Facturación: se ocupa del monto y del número de la factura.
Cuando una clase intenta abarcar las tres funciones, un campo de descuento termina al lado mismo de la dirección de envío. Cambiar uno implica el riesgo de dañar a los otros dos.
La solución es darle a cada área su propio modelo y permitir que se comuniquen mediante mensajes en lugar de usar una clase compartida.
class Cart {
lines: CartLine[];
discount: Money;
paymentIntentId: string;
}
class FulfillmentOrder {
orderId: string;
shipTo: Address;
picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));
Obsérvese que FulfillmentOrder se identifica únicamente por orderId y no contiene ningún dato relacionado con precios ni descuentos; el proceso de pago transmite esa información a la sección de cumplimiento a través del evento OrderPlaced, en lugar de exponer su propia clase interna. Eso significa que un cambio en los precios nunca podrá afectar la lógica del almacén, aunque el compilador lo permita. Cada una de estas áreas forma un contexto delimitado: su propio modelo, donde la palabra “orden” significa algo específico. En un monolito, esto podría ser simplemente un conjunto de módulos que poseen sus propias tablas de base de datos; en una arquitectura de microservicios, podría tratarse de un servicio completamente separado. En cualquier caso, rige la misma regla: nunca compartir un modelo más allá de los límites del contexto.
Una prueba útil es esta: un cambio dentro de un contexto nunca debería obligarte a editar otro. Si eso ocurre, tus límites están definidos en el lugar incorrecto.
Regla 2 — Un módulo es un dominio, no una capa
Imagina que tu gerente de producto solicita soporte para el empaquetado de regalos en los pedidos. Observa cuánto cuesta eso cuando el proyecto está organizado por roles técnicos en lugar de por área temática:
src/
├── controllers/ # order, auth, product, shipment, payment...
├── services/ # order, auth, product, shipment, payment...
├── entities/
└── enums/ # every enum in the whole app
Abres la carpeta controllers/ y desplazas el cursor más allá de las secciones de autenticación y envíos para encontrar el controlador de pedidos. Luego repites ese desplazamiento en services/, y de nuevo en entities/ y enums/. Cuatro o cinco carpetas, cuatro o cinco desplazamientos, y la única funcionalidad que estás agregando está dispersa en cada una de ellas.
Esa estructura responde a la pregunta “muéstrame cada controlador”, que en realidad casi nadie hace. La pregunta real de las personas es “muéstrame todo lo relacionado con los pedidos”. Por lo tanto, estructura el código primero por dominio:
modules/orders/
├── controllers/
├── dto/
├── entities/
│ ├── order.entity.ts
│ └── order-status.enum.ts # the enum sits next to what it uses
├── repositories/
└── orders.module.ts
Con esta estructura, el envoltorio de regalos solo afecta a una carpeta. Observa que no hay un directorio genérico enums/ en el nivel superior: un enum debe encontrarse junto a lo que describe. La única regla a seguir es que common/ solo debe contener elementos que no pertenezcan a ningún dominio en particular, como ayudantes de paginación o una clase de repositorio base. En cuanto common/ comience a saber qué es un pedido, en efecto se habrá convertido en otro módulo disfrazado.
Regla 3: Los módulos deben depender de interfaces, no de los servicios de otros módulos
Imagínese dos funcionalidades que se lanzan en el mismo sprint. La página del producto necesita mostrar “3 pedidos abiertos”, por lo que catalog accede e inyecta OrderService. Mientras tanto, el recibo necesita los nombres de los productos, así que orders inyecta ProductService. Nest se niega a conectar estos componentes:
Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.
Envolver la inyección en forwardRef() silencia el error, pero también fusiona permanentemente los dos módulos. La solución real es depender de una pequeña interfaz que se defina uno mismo, en lugar de acceder al servicio de otro módulo.
catalog necesita impedir la eliminación de un producto que aún está incluido en un pedido abierto, pero solo orders posee esa información. En lugar de importar orders, catalog simplemente define la pregunta a la que necesita respuesta:
export interface ProductUsageGuard {
isProductInUse(productId: string): Promise<boolean>;
}
El módulo orders proporciona la respuesta al implementar esa interfaz y registrarse a sí mismo, de modo que catalog puede hacer la consulta sin tener que importar nada de orders:
for (const guard of this.guards) {
if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before: catalog ⇄ orders circular — Nest won't boot
After: catalog ◄──implements── orders one way — catalog owns the interface
La dependencia ahora apunta en una sola dirección, por lo que no hay ciclo ni necesidad de forwardRef(). Como beneficio adicional, si más adelante aparece una regla como “no se puede eliminar un producto vinculado a una suscripción activa”, el módulo de suscripciones puede registrar su propio mecanismo de control, y catalog no necesita cambiar en absoluto.
Regla 4: Mantener los controladores simples y dejar que las entidades gestionen la lógica
Considere una regla como “no se puede cancelar un pedido que ya fue enviado”. ¿Dónde debería residir esa lógica? En muchos conjuntos de código termina donde se necesitó por primera vez, generalmente oculta dentro de un servicio. Luego, el panel de administración necesita la misma verificación, al igual que el job por lotes nocturno, e incluso un manejador de webhook. Cada lugar implementa la regla ligeramente de manera diferente, alguien olvida la cuarta copia, y de repente los pedidos ya enviados reciben reembolsos.
Cuando una entidad no es más que un conjunto de campos públicos que otro código modifica directamente, se obtiene un modelo débil; el síntoma siempre es el mismo: las reglas de negocio se filtran y se copian en todos los servicios que interactúan con los datos.
En lugar de eso, adjunte la regla al objeto que realmente posee el estado:
@Entity()
export class Order {
status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel an order that already shipped');
}
this.status = OrderStatus.CANCELLED;
}
Ahora existe exactamente un lugar donde está definido “cancel”, y ningún llamante tiene forma de eludir la verificación; simplemente no hay ruta alternativa. Es trivial realizar pruebas unitarias sin tocar la base de datos. La capa de servicio solo orquesta los pasos (order.cancel(), emitir el reembolso, persistir), mientras que el controlador se reduce a casi nada:
@Post(':id/cancel')
cancel(@Param('id') id: string) {
return this.orders.cancel(id);
}
He aquí la división de responsabilidades en una sola imagen:
HTTP ─► Controller ─► Service ─► Order (the rules)
└─────► Repository ─► DB (the queries)
Las entidades aplican las reglas, los repositorios manejan las consultas, los servicios coordinan la secuencia de llamadas, y los controladores se ocupan exclusivamente de HTTP.
Regla 5: Diseña tus datos de modo que no puedan existir estados inválidos
La regla 4 trasladó la lógica a las entidades. Otros dos patrones completan el trabajo, y cada uno evita un tipo específico de error.
Los objetos de valor manejan un primitivo que incluye reglas. El total de un pedido es simplemente un numero, lo que significa que nada impide que un cupón lo haga bajar de cero, o que un reembolso en EUR se aplique a un pedido facturado en USD. Las reglas que definen el “dinero” no se encuentran en ningún lugar específico. Para solucionarlo, se debe asignar al dinero su propio tipo que aplique esas reglas:
export class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (cents < 0) throw new Error('Money cannot be negative');
return new Money(cents, currency);
}
add(o: Money): Money {
if (o.currency !== this.currency) throw new Error('Currency mismatch');
return Money.of(this.cents + o.cents, this.currency);
}
}
Con esto en su lugar, ya no es posible crear un total negativo ni una discrepancia de moneda: el propio tipo lo impide. A esto se le denomina objeto de valor: un tipo pequeño e inmutable identificado por su valor y no por un ID. Úselo siempre que un primitivo vaya acompañado de reglas que deba verificar una y otra vez — dinero, direcciones de correo electrónico, rangos CIDR — pero omítalo en casos tan simples como un identificador puro.
Los agregados gestionan una regla que abarca varios objetos. El total de un pedido debe coincidir siempre con la suma de sus líneas. Si OrderLine tiene su propio repositorio, tarde o temprano alguien guardará una línea sin actualizar el pedido padre, y el total se desviará silenciosamente. La solución es no permitir nunca ese camino desde un principio: hacer que Order sea la raíz del agregado, el único objeto que se carga o guarda, el punto de entrada a esa parte del modelo. No existe OrderLineRepository; las líneas solo se modifican a través del propio pedido:
addLine(sku: string, price: Money, qty: number): void {
if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
this.lines.push(new OrderLine(sku, price, qty));
this.total = this.sumOfLines();
}
Con un único punto de entrada, la invariante no puede violarse por accidente. Mantenga sus agregados lo más pequeños posible, solo con aquello que realmente necesite cambiar dentro de la misma transacción, y refiérase a otros agregados por su ID en lugar de mantener referencias directas a los objetos.
Regla 6: Publicar eventos en lugar de llamar a los servicios directamente
El proceso de pago comienza de forma sencilla, pero luego place() sigue ampliándose: guardar el pedido, llamar al servicio de envíos, llamar al servicio de facturación, enviar un correo electrónico. En ese punto, orders acaba absorbiendo la mitad de la aplicación y debe estar al tanto de cada paso posterior. Si el próximo trimestre se añade una función de puntos de fidelidad, habrá que volver a editar el módulo de pago por algo que no tiene nada que ver con él.
Invierta la dirección. orders realiza su tarea y luego informa de lo que ha ocurrido; no tiene idea de quién, si es que hay alguien, está escuchando:
this.events.emit(new OrderPlaced(order.id, order.customerId, items));
Cada contexto interesado reacciona de forma independiente:
@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }
Agregar puntos de fidelidad ahora implica incluir un listener dentro del módulo de fidelización; orders permanece sin cambios. En servicios separados, la misma idea se aplica a través de un broker de mensajes (como RabbitMQ), con una medida adicional de seguridad: un buzón de salida transaccional. Escribes el evento en una tabla outbox dentro de la misma transacción en la que se guarda el pedido, y un trabajador separado lo publica posteriormente. Sin ese paso, una interrupción entre el guardado del pedido y la publicación del evento hace que este se pierda silenciosamente; en entornos de producción, esa es la diferencia entre un sistema fiable y uno problemático.
Una precaución: los eventos oscurecen el flujo general, ya que no hay un lugar único que muestre la secuencia completa de lo que ocurre. Úsalos para reacciones que trascienden los límites de contexto, no para pasos que forman parte de una tarea coherente.
Las ventajas
Aplicar DDD de forma selectiva no se trata de añadir más capas arquitectónicas. Se trata de colocar cada pieza lógica donde le corresponde: los modelos manejan las reglas, los módulos gestionan sus dominios, los repositorios se encargan de las consultas y los eventos conectan un contexto con otro. Si te mantienes dentro de estos límites, tu aplicación NestJS será más fácil de entender, modificar y hacer evolucionar con el tiempo.
Lecturas relacionadas
- Por qué NestJS gana en equipos y codigos backend en crecimiento — Explora cómo la estructura de NestJS, la inyección de dependencias y su diseño basado en TypeScript ayudan a los equipos de ingeniería a escalar sin caer en el caos.