Patrón Aggregate: límites de consistencia en DDD
Encapsula un cluster de objetos de dominio tratado como una unidad única para cambios de datos. Un Aggregate Root controla el acceso a sus entidades internas y value objects.
Descripción General
Todo código base tiene ese grafo de objetos donde cualquier cosa puede alcanzar cualquier cosa — cambia una línea de pedido directamente y el total de la orden queda desactualizado, salta el padre y un invariante se rompe en silencio. El patrón Aggregate dibuja el límite: un cluster de objetos relacionados tratados como una unidad para los cambios de datos, con una única entidad raíz — el Aggregate Root — controlando todo acceso.
El código externo solo puede referenciar a la raíz. Las entidades internas y value objects no pueden modificarse independientemente — todo cambio pasa por la raíz, que es exactamente lo que permite al aggregate forzar invariantes que abarcan múltiples objetos.
Cómo Se Mantiene el Límite
Las reglas que lo hacen funcionar en la práctica:
- Un aggregate = una transacción. Si un cambio no cabe dentro del límite, o el límite está mal o el cambio cruza aggregates — y la consistencia entre aggregates es eventual, no inmediata.
- Referencias solo por ID. Los aggregates guardan identificadores de otros, no referencias de objetos — las trampas de carga diferida y los updates en cascada desaparecen.
- Un repositorio por aggregate. El repo carga y guarda el aggregate completo; sin persistencia parcial, sin internos que se filtran.
- Pequeño es correcto. Un aggregate que envuelve medio dominio es un monolito disfrazado — cuando el límite deja de caber en una transacción, divídelo.
Cuándo Dividir un Aggregate Grande
Un aggregate inflado es el fallo más común del patrón — Order creciendo OrderHistory, OrderAnalytics, OrderNotifications hasta que cargar una orden cuesta media base de datos. Divide cuando: las entidades no comparten invariantes (si OrderAnalytics nunca restringe a Order, es su propio aggregate), la transacción no las necesita juntas, o aparece contención (ediciones concurrentes en partes distintas del mismo aggregate significa que el límite es demasiado grande).
La señal: si estás alcanzando conflictos de @Version en campos que lógicamente no deberían contender, el aggregate se tragó una unidad de consistencia separada.
Orientado a eventos o no, la disciplina se mantiene: una transacción por aggregate, referencias por ID, la raíz como única puerta. Rompe cualquiera de las tres y el patrón es decoración — el límite existe en el diagrama pero no en el comportamiento, que es el único sitio donde cuenta.
Cuándo Usar
Usa el Patrón Aggregate cuando:
- Una regla de negocio involucra consistencia a través de múltiples objetos relacionados
- Necesitas forzar invariantes que abarcan un cluster de entidades
- Los cambios a objetos internos deben ser controlados y validados
- El modelo de dominio tiene límites transaccionales naturales
Cuándo Evitar
- CRUD simple sobre entidades independientes no necesita límites de aggregate
- Aggregates demasiado grandes causan cuellos de botella de concurrencia (evita “god aggregates”)
- El sistema usa event sourcing exclusivamente (los aggregates pueden modelarse diferentemente)
Solución
Python
from dataclasses import dataclass, field
from typing import List
from datetime import datetime
import uuid
@dataclass(frozen=True)
class OrderLine:
product_id: str
quantity: int
unit_price: float
def total(self) -> float:
return self.quantity * self.unit_price
class Order:
def __init__(self, customer_id: str):
self.id = str(uuid.uuid4())
self.customer_id = customer_id
self.lines: List[OrderLine] = []
self.status = "pending"
self.created_at = datetime.now()
self.version = 0
def add_line(self, product_id: str, quantity: int, unit_price: float):
if quantity <= 0:
raise ValueError("La cantidad debe ser positiva")
if self.status != "pending":
raise ValueError("No se puede modificar un pedido no pendiente")
self.lines.append(OrderLine(product_id, quantity, unit_price))
self.version += 1
def remove_line(self, product_id: str):
if self.status != "pending":
raise ValueError("No se puede modificar un pedido no pendiente")
self.lines = [line for line in self.lines if line.product_id != product_id]
self.version += 1
def total(self) -> float:
return sum(line.total() for line in self.lines)
def submit(self):
if not self.lines:
raise ValueError("No se puede enviar un pedido vacío")
self.status = "submitted"
self.version += 1
# Uso
order = Order(customer_id="cust-123")
order.add_line("prod-1", 2, 9.99)
order.add_line("prod-2", 1, 19.99)
print(f"Total: {order.total():.2f}") # Total: 39.97
order.submit()
Java
import java.time.Instant;
import java.util.*;
public class Order {
private final UUID id;
private final String customerId;
private final List<OrderLine> lines = new ArrayList<>();
private String status = "pending";
private final Instant createdAt;
private int version = 0;
public Order(String customerId) {
this.id = UUID.randomUUID();
this.customerId = customerId;
this.createdAt = Instant.now();
}
public void addLine(String productId, int quantity, double unitPrice) {
if (quantity <= 0) throw new IllegalArgumentException("Cantidad debe ser positiva");
if (!"pending".equals(status)) throw new IllegalStateException("No se puede modificar pedido enviado");
lines.add(new OrderLine(productId, quantity, unitPrice));
version++;
}
public void removeLine(String productId) {
if (!"pending".equals(status)) throw new IllegalStateException("No se puede modificar pedido enviado");
lines.removeIf(line -> line.productId().equals(productId));
version++;
}
public double total() {
return lines.stream().mapToDouble(OrderLine::total).sum();
}
public void submit() {
if (lines.isEmpty()) throw new IllegalStateException("No se puede enviar pedido vacío");
status = "submitted";
version++;
}
public UUID getId() { return id; }
public String getStatus() { return status; }
public int getVersion() { return version; }
}
record OrderLine(String productId, int quantity, double unitPrice) {
public double total() { return quantity * unitPrice; }
}
JavaScript
class Order {
constructor(customerId) {
this.id = crypto.randomUUID();
this.customerId = customerId;
this.lines = [];
this.status = 'pending';
this.createdAt = new Date();
this.version = 0;
}
addLine(productId, quantity, unitPrice) {
if (quantity <= 0) throw new Error('La cantidad debe ser positiva');
if (this.status !== 'pending') throw new Error('No se puede modificar pedido enviado');
this.lines.push({ productId, quantity, unitPrice });
this.version++;
}
removeLine(productId) {
if (this.status !== 'pending') throw new Error('No se puede modificar pedido enviado');
this.lines = this.lines.filter(line => line.productId !== productId);
this.version++;
}
total() {
return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
}
submit() {
if (this.lines.length === 0) throw new Error('No se puede enviar pedido vacío');
this.status = 'submitted';
this.version++;
}
}
// Uso
const order = new Order('cust-123');
order.addLine('prod-1', 2, 9.99);
order.addLine('prod-2', 1, 19.99);
console.log(order.total().toFixed(2)); // 39.97
order.submit();
Explicación
Un aggregate tiene tres límites:
- Aggregate Root: La entidad de nivel superior que el código externo referencia. Tiene una identidad global.
- Internal Entities: Objetos con identidad significativa solo dentro del aggregate (ej.,
OrderLineidentificado por product ID dentro de un pedido). - Value Objects: Objetos inmutables dentro del aggregate que no tienen identidad (ej.,
Money,Address).
Todas las modificaciones fluyen a través de la raíz. Esto asegura que invariantes como “un pedido debe tener al menos una línea para ser enviado” siempre se cumplan.
Aggregates y Bounded Context
El aggregate dibuja un límite de consistencia; el bounded context dibuja un límite de significado — dónde una palabra (“order”, “customer”, “account”) significa una cosa. Son herramientas distintas que se confunden a menudo: los aggregates dividen dentro de un contexto (Order y Customer son aggregates separados en el mismo contexto Sales), mientras los contextos dividen a través del dominio (el “Order” de Shipping no es el mismo objeto que el “Order” de Sales, y no debería intentar serlo).
Hacer esto bien arriba lo cambia todo abajo: si el límite del bounded context está mal, los aggregates se construyen alrededor de invariantes equivocados y ninguna disciplina de transacciones lo arregla. La secuencia que funciona: contexto primero (¿qué significa “orden” aquí?), luego aggregates (¿qué objetos deben cambiar juntos bajo ese significado?).
Aggregates vs. las Alternativas
| Enfoque | Consistencia | Complejidad | Cuándo gana |
|---|---|---|---|
| Navegación libre del grafo | Cualquier cosa actualiza cualquier cosa | Baja al escribir, alta al razonar | Scripts, prototipos |
| Límite aggregate | La raíz fuerza invariantes | Moderada | Lógica de negocio, dominios compartidos |
| Aggregate event-sourced | Los eventos son el estado | Alta | Auditorías, consultas temporales |
La mayoría de apps CRUD no necesitan aggregates en absoluto — una tabla bien diseñada y un método de servicio hacen el trabajo. El patrón rinde cuando los invariantes abarcan objetos: totales de orden, reservas de inventario, estados de flujos de trabajo. Si el invariante vive en una fila, el aggregate es ceremonia.
Variantes
| Variante | Alcance | Caso de Uso |
|---|---|---|
| Aggregate Estándar | Root + entidades + value objects | Pedido con líneas, cliente con direcciones |
| Large Aggregate | Root con muchos niveles | Catálogo de productos con categorías, variantes, precios |
| Event-Sourced Aggregate | Rehidratado desde stream de eventos | Cuenta bancaria reconstruida desde eventos Deposit / Withdraw |
Lo que funciona
- Mantén los aggregates pequeños. Un buen aggregate cabe en memoria y se carga en una única query de base de datos. Aggregates grandes afectan performance.
- Referencia otros aggregates por ID. No mantengas referencias directas a objectos de otros aggregate roots. Esto previene cargar el grafo completo.
- Una transacción por aggregate. No modifiques dos aggregates en la misma transacción. Usa consistencia eventual y eventos de dominio para coordinación cross-aggregate.
- Versiona los aggregates para optimistic locking. Incrementa un campo versión en cada cambio para detectar modificaciones concurrentes.
- Valida invariantes dentro del aggregate. Las reglas de negocio pertenecen al modelo de dominio, no a los servicios de aplicación.
Errores Comunes
- God aggregates que cargan cientos de objetos causan problemas de base de datos y memoria. Divide en aggregates más pequeños.
- Modificación directa de entidades internas rompe la encapsulación. Todos los cambios deben pasar por la raíz.
- Transacción a través de aggregates crea acoplamiento y contención de locks. Publica un evento de dominio en su lugar.
- Anemic domain models donde los aggregates son solo bolsas de datos con getters y setters. Pon comportamiento en el aggregate.
- Ignorar consistencia eventual entre aggregates. Acepta que aggregates separados pueden estar temporalmente inconsistentes.
Aggregates en el Mundo Real
La firma del patrón está por todas partes una vez que miras: @Aggregate y @OneToMany(orphanRemoval) de JPA/Hibernate lo codifican, Axon y EventSourcingDB lo modelan directamente, y la convención de “entidad raíz” de cada ORM es el patrón con otra ropa. La señal honesta de un aggregate real: un deleteOrder() que no existe — pasas por order.cancel() porque la raíz es la única puerta.
Ejemplos del Mundo Real
Pedido E-Commerce
Un aggregate Order contiene OrderLines, un value object ShippingAddress, y referencias Payment. La raíz del pedido fuerza que los totales coincidan con la suma de líneas y que los pedidos enviados no puedan modificarse.
Cuenta Bancaria
Un aggregate Account contiene entidades Transaction. La raíz asegura que el balance nunca baje de cero (reglas de sobregiro) y que las transacciones sean inmutables una vez registradas.
Carrito de Compras
Un aggregate Cart contiene entidades CartItem. Al agregar un item para un producto existente, incrementa la cantidad en lugar de agregar una línea duplicada.
Reserva de Inventario
Un aggregate Product en el dominio de almacén contiene StockLevel más entidades Reservation. Reservar stock pasa por product.reserve(qty) — la raíz comprueba disponible = stock − reservas antes de decir que sí. Cuando dos pagos compiten por la última unidad, el campo de versión decide quién se la lleva.
Lectura Adicional
- Eric Evans — Domain-Driven Design — el libro que definió los aggregates
- Vaughn Vernon — Implementing Domain-Driven Design — la guía práctica de dimensionado de aggregates
- Microsoft — Diseñar un microservicio orientado a DDD — aggregates en la práctica
Preguntas frecuentes
¿Qué tan grande debe ser un aggregate?
Tan pequeño como pueda ser mientras siga guardando sus invariantes. Si dos objetos pueden cambiarse de forma independiente, van en aggregates distintos.
¿Puedo referenciar otro aggregate dentro de un aggregate?
Por ID, nunca por referencia a objeto. Un Order guarda customerId, no un Customer — eso es lo que mantiene cada aggregate cargable de forma independiente.
¿Cómo fuerza un aggregate reglas entre aggregates?
No lo hace, y no se supone que lo haga. La consistencia cross-aggregate viaja en eventos de dominio y consistencia eventual — una transacción que abarca dos aggregates es una señal de mal diseño.
¿Qué pasa cuando dos usuarios editan el mismo aggregate a la vez?
El campo de versión lo detecta. El primer escritor confirma y sube la versión; el save del segundo falla el check de optimistic lock y recarga — sin pérdida silenciosa de actualizaciones.
¿Debería toda entidad ser una aggregate root?
No — las raíces son para entidades que poseen invariantes. Un LineItem solo existe dentro de Order, así que no tiene puerta propia. Haz de todo una raíz y habrás reconstruido el grafo libre-para-todo que el patrón existe para evitar.
Recursos Relacionados
Patrón Value Object
Modela conceptos de dominio por valor en lugar de identidad. Un objeto inmutable definido por sus atributos, no por un ID único.
PatternPatrón Repository
Abstrae la lógica de acceso a datos detrás de una interfaz limpia. Patrón de diseño arquitectural para capas de datos testeables y mantenibles.
PatternPatrón Outbox
Publica eventos de dominio de forma confiable persistiéndolos en una tabla outbox dentro de la misma transacción de base de datos que la operación de negocio.
PatternPatrón Domain Event
Captura y publica ocurrencias importantes dentro de un modelo de dominio para desacoplar efectos secundarios de la lógica de negocio central y habilitar workflows reactivos.
PatternPatrón Null Object
Usa un objeto por defecto en lugar de referencias null para eliminar verificaciones de null y simplificar el código cliente. Un patrón behavioral para defaults más seguros.
PatternPatrón Plugin
Habilita extensiones de terceros definiendo puntos de extensión en un sistema host que carga y ejecuta módulos externos en vivo.