Domain-Driven Design: Aggregates, límites para proteger reglas y consistencia
Diseña un Order Aggregate con una raíz que controla sus líneas y protege reglas comunes. Con TypeScript y pruebas ejecutables, aprende a elegir límites de consistencia, evitar cambios parciales y distinguir las garantías del modelo de las que requiere la persistencia transaccional.
Un pedido tiene dos libros y dos cuadernos. Su total está dentro del presupuesto autorizado. Una pantalla permite aumentar la cantidad de una línea directamente; otra permite modificar el precio. Cada línea parece válida por separado, pero el pedido termina superando el presupuesto.
Añadir validaciones a OrderItem no basta para comprobar una condición que depende de todas las líneas. Tampoco basta con recalcular el total después de guardar: para entonces ya podrías haber aceptado un estado que el negocio rechaza.
Un Aggregate permite definir qué objetos deben mantener ciertas reglas conjuntamente y quién controla sus modificaciones. La decisión importante no es cuántas clases cuelgan de Order, sino qué condiciones deben preservarse como una unidad.
Aggregate y Aggregate Root no son lo mismo
Un Aggregate es un conjunto de Entities y Value Objects tratado como una unidad para mantener su consistencia. Su Aggregate Root es la Entity que controla el acceso y las modificaciones del conjunto. Evans presenta este límite y el papel de la raíz en la sección Aggregates de su referencia de DDD.
En nuestro ejemplo, el Aggregate incluye Order, sus OrderItem y los valores monetarios utilizados. Order es la raíz, no el nombre de todo el patrón:
Order Aggregate
Order [Aggregate Root]
OrderItem: book
Money: precio acordado
OrderItem: notebook
Money: precio acordado
Referencias externas:
customerId -> Customer
productId -> Product
Customer y Product no entran automáticamente en el Aggregate porque el pedido los mencione. El precio acordado en una línea tampoco debe cambiar silenciosamente cuando se modifica el catálogo.
Un Module organiza conceptos del modelo. Un Aggregate establece un límite de consistencia. Un Module puede contener distintos tipos de Aggregates; compartir carpeta no implica compartir transacción.
Las Invariants son el punto de partida
Una Invariant es una condición que debe mantenerse en los estados válidos del modelo. Puede afectar a un solo objeto o relacionar componentes del Aggregate.
Para este pedido acordaremos lo siguiente:
Cada línea tiene una cantidad entera positiva.
No se repiten identificadores de línea ni productos dentro del pedido.
La suma de cantidades no supera diez unidades.
El importe total no supera el presupuesto autorizado al crear el pedido.
Confirmar exige al menos una línea y deja fija la composición del pedido.
Son reglas elegidas para este caso. Confirmar significa aceptar esa composición, no demostrar que ya se cobró ni que existe stock reservado.
La cantidad positiva puede comprobarla OrderItem. El total de unidades y el presupuesto requieren considerar el conjunto. La raíz debe coordinar esos cambios y rechazar una operación antes de dejar el estado parcialmente modificado.
La función puede asignar cero, superar diez unidades o romper el presupuesto. Incluso si validara todo eso, otro consumidor podría modificar el arreglo directamente.
La alternativa será ofrecer order.changeQuantity(itemId, quantity). La raíz construirá un cambio candidato, comprobará el conjunto y solo entonces lo aceptará. Los consumidores podrán leer descripciones de las líneas, pero no recibirán referencias modificables a los componentes internos.
Ejemplo completo: Order controla sus OrderItem
Alcance y ejecución
El siguiente programa es autocontenido. Guárdalo como order-aggregate.test.ts y ejecútalo con:
node --test order-aggregate.test.ts
Usa Node.js 24.21.0 y sus módulos incluidos, sin paquetes externos. La ejecución directa elimina anotaciones de TypeScript mediante type stripping, pero no comprueba estáticamente los tipos.
Los importes son unidades menores enteras no negativas de EUR. El Money mínimo solo contiene las operaciones necesarias; no introduce conversión, impuestos ni redondeos. Los precios recibidos se consideran autorizados por el proceso que construye el pedido: validarlos numéricamente no demuestra que sean precios comerciales correctos.
El ejemplo trabaja en memoria y no incluye persistencia ni reconstrucción desde datos guardados. Los identificadores de línea se proporcionan explícitamente y solo necesitan distinguirse dentro del pedido.
import assert from'node:assert/strict';
import test from'node:test';
functionrequireId(value: string): string {
if (typeof value !== 'string' || !value.trim()) thrownewError('Identificador vacío');
return value.trim();
}
classMoney {
readonlyminorUnits: number;
privateconstructor(minorUnits: number) {
if (!Number.isSafeInteger(minorUnits) || minorUnits < 0) {
thrownewError('Importe EUR inválido');
}
this.minorUnits = minorUnits;
Object.freeze(this);
}
staticeur(minorUnits: number): Money { returnnewMoney(minorUnits); }
add(other: Money): Money { returnMoney.eur(this.minorUnits + other.minorUnits); }
times(quantity: number): Money { returnMoney.eur(this.minorUnits * quantity); }
}
classOrderItem {
#id: string;
#productId: string;
#quantity: number;
#unitPrice: Money;
constructor(id: string, productId: string, quantity: number, unitPrice: Money) {
if (!Number.isSafeInteger(quantity) || quantity <= 0) {
thrownewError('La cantidad debe ser un entero positivo');
}
this.#id = requireId(id);
this.#productId = requireId(productId);
this.#quantity = quantity;
this.#unitPrice = unitPrice;
}
getid(): string { returnthis.#id; }
getproductId(): string { returnthis.#productId; }
getquantity(): number { returnthis.#quantity; }
total(): Money { returnthis.#unitPrice.times(this.#quantity); }
withQuantity(quantity: number): OrderItem {
returnnewOrderItem(this.#id, this.#productId, quantity, this.#unitPrice);
}
describe() {
return {
id: this.#id, productId: this.#productId, quantity: this.#quantity,
unitPriceMinorUnits: this.#unitPrice.minorUnits,
};
}
}
classOrder {
#id: string;
#customerId: string;
#budget: Money;
#status: 'draft' | 'confirmed' = 'draft';
#items = newMap<string, OrderItem>();
constructor(id: string, customerId: string, budget: Money) {
this.#id = requireId(id);
this.#customerId = requireId(customerId);
this.#budget = budget;
}
addItem(id: string, productId: string, quantity: number, unitPrice: Money): void {
this.#requireDraft();
const candidate = newOrderItem(id, productId, quantity, unitPrice);
if (this.#items.has(candidate.id)) thrownewError('Línea duplicada');
if ([...this.#items.values()].some(item => item.productId === candidate.productId)) {
thrownewError('Producto duplicado');
}
const next = newMap(this.#items);
next.set(candidate.id, candidate);
this.#accept(next);
}
changeQuantity(itemId: string, quantity: number): void {
this.#requireDraft();
const current = this.#items.get(itemId);
if (!current) thrownewError('Línea inexistente');
const next = newMap(this.#items);
next.set(itemId, current.withQuantity(quantity));
this.#accept(next);
}
removeItem(itemId: string): void {
this.#requireDraft();
const next = newMap(this.#items);
if (!next.delete(itemId)) thrownewError('Línea inexistente');
this.#accept(next);
}
confirm(): void {
this.#requireDraft();
if (this.#items.size === 0) thrownewError('Un pedido vacío no puede confirmarse');
this.#status = 'confirmed';
}
#total(items: Map<string, OrderItem>): Money {
let total = Money.eur(0);
for (const item of items.values()) total = total.add(item.total());
return total;
}
#accept(next: Map<string, OrderItem>): void {
const units = [...next.values()].reduce((sum, item) => sum + item.quantity, 0);
if (units > 10) thrownewError('El pedido supera diez unidades');
if (this.#total(next).minorUnits > this.#budget.minorUnits) {
thrownewError('El pedido supera el presupuesto');
}
// El estado interno solo cambia después de validar el candidato completo.this.#items = next;
}
#requireDraft(): void {
if (this.#status !== 'draft') thrownewError('El pedido ya está confirmado');
}
snapshot() {
return {
id: this.#id, customerId: this.#customerId, status: this.#status,
items: [...this.#items.values()].map(item => item.describe()),
totalMinorUnits: this.#total(this.#items).minorUnits,
};
}
}
functionexampleOrder(): Order {
const order = newOrder('order-1', 'customer-7', Money.eur(5000));
order.addItem('line-1', 'book', 2, Money.eur(1200));
order.addItem('line-2', 'notebook', 2, Money.eur(300));
return order;
}
test('la raíz rechaza cambios que romperían reglas del conjunto', () => {
const order = exampleOrder();
const before = order.snapshot();
assert.equal(before.totalMinorUnits, 3000);
assert.throws(() => order.changeQuantity('line-1', 4), /presupuesto/);
assert.deepEqual(order.snapshot(), before);
assert.throws(() => order.changeQuantity('line-2', 9), /diez unidades/);
assert.deepEqual(order.snapshot(), before);
assert.throws(() => order.changeQuantity('line-1', 0), /entero positivo/);
assert.deepEqual(order.snapshot(), before);
});
test('no se duplican líneas ni productos y una lectura no modifica el Aggregate', () => {
const order = exampleOrder();
assert.throws(() => order.addItem('line-1', 'pen', 1, Money.eur(10)), /Línea duplicada/);
assert.throws(() => order.addItem('line-3', 'book', 1, Money.eur(1200)), /Producto duplicado/);
const view = order.snapshot();
view.items[0].quantity = 100;
view.items.pop();
assert.equal(order.snapshot().items.length, 2);
assert.equal(order.snapshot().totalMinorUnits, 3000);
});
test('confirmar deja fija la composición y eliminar recalcula el total', () => {
const order = exampleOrder();
order.removeItem('line-2');
assert.equal(order.snapshot().totalMinorUnits, 2400);
order.changeQuantity('line-1', 3);
assert.equal(order.snapshot().totalMinorUnits, 3600);
order.confirm();
assert.equal(order.snapshot().status, 'confirmed');
assert.throws(() => order.changeQuantity('line-1', 1), /confirmado/);
assert.throws(() => order.removeItem('line-1'), /confirmado/);
assert.throws(() => order.addItem('line-3', 'pen', 1, Money.eur(10)), /confirmado/);
});
test('un presupuesto exacto y diez unidades son válidos; confirmar vacío no', () => {
const order = newOrder('order-limit', 'customer-7', Money.eur(1000));
assert.throws(() => order.confirm(), /vacío/);
order.addItem('line-1', 'pen', 10, Money.eur(100));
order.confirm();
assert.equal(order.snapshot().totalMinorUnits, 1000);
});
Qué cambia entre la entrada y el resultado
La entrada contiene dos libros de 12 EUR y dos cuadernos de 3 EUR: total, 30 EUR. Cambiar los libros a cuatro elevaría el total a 54 EUR, por encima del presupuesto de 50 EUR.
changeQuantity() no cambia primero la línea existente. Construye otra representación con la misma identidad interna, la coloca en una colección candidata y valida el conjunto. El rechazo conserva el total de 30 EUR y las cantidades originales.
OrderItem sigue teniendo responsabilidad: no acepta cantidades inválidas. Order añade las comprobaciones que requieren varias líneas y controla cuándo pueden modificarse. El total se calcula a partir del contenido; no existe otro campo mutable que pueda quedar desincronizado.
Las cuatro pruebas se ejecutaron y pasaron con Node.js 24.21.0. Comprueban las operaciones del modelo en memoria; no se realizó análisis estático con TypeScript ni se verificó un adaptador de base de datos.
Las lecturas devuelven objetos nuevos con campos primitivos. Por eso cambiar una cantidad en snapshot() no cambia el Aggregate. Ocultar únicamente el arreglo, pero devolver sus instancias modificables, no habría protegido ese límite.
Un Domain Service podría proponer una distribución de descuento o una selección de productos. La raíz seguiría decidiendo si el cambio es válido; el servicio no debería modificar las líneas por una puerta alternativa.
El límite del objeto debe sostenerse al guardar
En memoria hemos protegido operaciones, no demostrado persistencia transaccional. Un Aggregate no es una transacción de base de datos por el hecho de estar implementado como clase.
Si guardas Order y OrderItem en tablas diferentes, los cambios relevantes del conjunto deben confirmarse o rechazarse como una unidad. No deberías guardar una cantidad y dejar otro dato necesario a medio actualizar por un fallo.
Un Repository, el componente que ofrece recuperar y guardar el Aggregate sin exponer el almacenamiento al consumidor, normalmente trabaja a través de la raíz para estas modificaciones. Eso no prohíbe consultas de lectura específicas sobre líneas: leer una proyección no equivale a modificar un componente saltándose las reglas.
Dos instancias válidas pueden competir
Imagina dos procesos que cargan la misma versión del pedido. Uno aumenta los libros y otro los cuadernos. Cada instancia puede aceptar un cambio válido por separado. Si el almacenamiento combina escrituras sin detectar el conflicto, el resultado conjunto puede superar el presupuesto.
Con Optimistic Concurrency Control, guardas condicionado a que la versión almacenada siga siendo la que cargaste. La comprobación de versión y las escrituras de la raíz y sus componentes deben formar parte de la misma transacción local. Si no coincide, rechazas el guardado y reviertes sus cambios.
Un reintento requiere cargar un estado coherente y volver a evaluar las reglas; no repetir ciegamente una escritura vieja. Otra estrategia puede utilizar bloqueos adecuados. Elegir y probar ese mecanismo corresponde al diseño real de persistencia, que el programa anterior no implementa.
La consistencia inmediata significa aquí que las Invariants internas se cumplen al terminar cada operación aceptada y al confirmar la unidad transaccional. No significa que todos los usuarios o sistemas vean instantáneamente la última versión.
Cómo decidir qué debe estar dentro
Pregunta qué elementos deben comprobarse juntos para aceptar una operación y qué coste tendría separarlos. No uses la navegación del ORM o las relaciones entre tablas como definición del límite.
En nuestro caso, las cantidades y precios de las líneas participan en el presupuesto del pedido. Eso justifica controlar el conjunto. El perfil completo del cliente, sus pedidos anteriores y todo el catálogo no necesitan entrar para validar esa regla.
Referenciar otros Aggregates por identidad ayuda a evitar recorridos que terminan modificando varios conjuntos sin reconocer sus límites. Vernon desarrolla esta recomendación y la colaboración entre Aggregates en Making Aggregates Work Together.
No reduzcas el límite a una convención de «una Entity siempre». Separar cada línea del pedido sin resolver cómo proteger el presupuesto trasladaría el problema, no lo eliminaría. Tampoco agrandes el pedido hasta incluir todo lo relacionado con una venta.
Educación: proteger un cupo sin incluir toda la academia
Un taller admite veinte personas. Si cada inscripción independiente solo comprueba su propio estudiante, ninguna protege por sí sola «no ocupar más de veinte plazas».
Una opción para un grupo pequeño es un Aggregate de reservas de la edición que controle sus plazas y las identidades inscritas. Dos solicitudes para la última plaza deben competir mediante el mecanismo de concurrencia de esa unidad.
Eso no exige incluir dentro historiales, evaluaciones ni certificados de cada persona. La relación entre esos datos y la edición no los hace necesarios para proteger el cupo.
Si la colección crece mucho o recibe demasiadas escrituras, quizá necesites otro modelo de asignación de capacidad. No basta con dividir las inscripciones y aceptar que el contador se actualice después si el negocio exige impedir sobreventas. Este caso enseña a diferenciar una restricción fuerte de un dato que puede ponerse al día más tarde.
Logística: dos Aggregates pueden progresar en momentos distintos
Pedido y envío suelen tener ritmos distintos. Confirmar la composición del pedido no garantiza que el transportista ya haya aceptado el paquete. Meter toda la vida del envío dentro del pedido puede aumentar conflictos y responsabilidades compartidas sin proteger una regla que lo requiera.
Puedes aceptar un estado explícito como «pendiente de preparación logística» y registrar de forma durable el trabajo que debe continuar después. Cuando logística confirma o rechaza la preparación, el proceso actualiza lo que corresponda según las reglas acordadas.
Esto utiliza Eventual Consistency: los cambios relacionados se coordinan en pasos diferentes y convergen mediante un proceso diseñado. No significa «ya se actualizará»: hacen falta tratamiento de fallos, repeticiones, seguimiento de operaciones pendientes y una política cuando el paso posterior no puede completarse.
Si Order y Shipment pertenecen además a Bounded Contexts distintos, conserva la separación de sus implementaciones. En un diseño con integraciones por ACL HTTP, el consumidor define qué necesita y el adaptador traduce la respuesta. HTTP no comparte la transacción de tu base de datos.
Usar consistencia eventual entre Aggregates es una orientación de diseño, no una ley que obligue a aceptarla para cualquier requisito. Si dos cambios necesitan atomicidad estricta, revisa el límite o justifica una coordinación transaccional posible en tu entorno. Elige la garantía antes del mecanismo; no prometas simultaneidad que la integración no proporciona.
El coste de un Aggregate demasiado grande
Un Aggregate que contiene todo el cliente y todos sus pedidos obliga a considerar un conjunto enorme para cambios pequeños. Puede aumentar carga, memoria, conflictos de versión y duración de transacciones.
Un límite demasiado pequeño también tiene coste: dispersa reglas que requieren consistencia conjunta. La medida útil es la relación entre las Invariants y los elementos necesarios para protegerlas, no un número universal de clases o registros.
Puede haber un Aggregate de una sola Entity. Una nota editorial con un título obligatorio quizá solo necesite su raíz y una validación sencilla. No añadas componentes internos, servicios o mecanismos distribuidos para que parezca más completo.
Prompts para orientar y revisar Aggregates con IA
Proponer límites a partir de reglas
Analiza estas reglas del pedido: cantidades positivas, productos únicos, máximo diez unidades y total dentro del presupuesto. Propón el límite de Aggregate, su raíz y qué referencias deben mantenerse por identidad. No implementes ni incluyas Customer o Product completos por defecto. Identifica qué reglas exigen consistencia inmediata y qué información falta. Una persona validará las restricciones reales y el alcance transaccional antes de aceptar el diseño.
Implementar cambios sin estados parciales
Implementa Order y OrderItem en TypeScript sin dependencias. Toda modificación entra por Order. Cambiar una cantidad debe construir y validar un candidato antes de sustituir el estado; las lecturas no exponen instancias internas modificables. Entrega un programa autocontenido y pruebas de presupuesto, límites exactos, duplicados y rechazo sin cambios parciales. No añadas persistencia. Una persona ejecutará las pruebas y revisará los caminos alternativos de modificación.
Revisar las garantías del sistema real
Revisa el modelo y el adaptador de guardado adjuntos. Busca escrituras directas sobre componentes internos, cambios parciales, comprobaciones de versión separadas de las escrituras y reintentos que no recargan el Aggregate. No modifiques archivos ni supongas transacciones compartidas por HTTP. Devuelve evidencia y un escenario concurrente por hallazgo. Una persona validará el aislamiento, el control de concurrencia y las pruebas contra el almacenamiento real.
Las pruebas generadas por IA pueden comprobar cada línea y olvidar la regla del conjunto. Revisa siempre escenarios donde varios componentes válidos por separado producen una combinación no admitida.
Tres ejercicios con solución razonada
1. Una referencia interna que escapa
La IA propone getItems(): OrderItem[] y devuelve un arreglo nuevo con los mismos objetos internos, que ahora tienen setQuantity(). ¿El arreglo nuevo basta?
Solución razonada: no. Cada elemento conserva una referencia modificable al componente interno y permite evitar la raíz. Devuelve descripciones independientes o una representación verdaderamente inmutable y mantén las operaciones de cambio en Order. Prueba que modificar una lectura no altere el pedido.
2. Un presupuesto global del cliente
El negocio exige que la suma de todos los pedidos abiertos de un cliente no supere un crédito común. ¿La regla local de Order basta?
Solución razonada: no. Dos pedidos pueden respetar sus límites locales y superar el crédito conjuntamente. Necesitas modelar quién controla y reserva ese crédito, además de coordinar sus resultados con los pedidos. No añadas todos los pedidos a Customer automáticamente: examina una unidad de capacidad o crédito con su propia garantía de concurrencia y decide qué pasos pueden quedar pendientes.
3. El último cupo y una inscripción duplicada
Se separan las inscripciones y se actualiza el número de plazas ocupadas al final de cada minuto. ¿Cumple «nunca aceptar más de veinte»?
Solución razonada: no por sí solo. Una proyección atrasada puede informar plazas que ya no existen. La asignación debe proteger la capacidad con una garantía inmediata adecuada; el contador de lectura puede actualizarse después. Diferencia el dato usado para mostrar disponibilidad de la decisión que concede efectivamente una plaza.
Cómo reconocer un límite de consistencia bien elegido
Identifica las reglas que deben mantenerse juntas, los componentes que participan y la raíz que controla sus cambios. Después comprueba rechazos, lecturas, persistencia y concurrencia: cada uno puede abrir un camino distinto hacia un estado inválido.
Un Aggregate útil reúne lo necesario para proteger esas reglas y mantiene fuera lo que puede colaborar mediante identidades y procesos explícitos. Esa relación entre límite, comportamiento y garantía transaccional permite evaluar el diseño con evidencia, no solo por la forma del diagrama.
Diseña un OrderRepository independiente del almacenamiento y compáralo con un DAO, un ORM y un Application Service. Implementa adaptadores en memoria y SQLite, recupera Aggregates completos y comprueba guardado, conflictos de versión y rollback con pruebas ejecutables.
Aprende cuándo usar un constructor, un método estático o una Factory dedicada para crear objetos de dominio completos. Construye un pedido desde una cotización en TypeScript, comprueba sus invariantes y distingue una creación nueva de la reconstitución de un estado persistido.
Aprende a definir Modules a partir de responsabilidades del negocio, ofrecer interfaces públicas y controlar sus dependencias. Con un ejemplo ejecutable en TypeScript, distingue un módulo de una carpeta, una layer o un Bounded Context y establece límites útiles para trabajar con IA.