Capítulo 3: Agregados y Boundaries
Capítulo 3: Agregados y Boundaries
“Un agregado es un cluster de objetos de dominio que se tratan como una unidad”
¿Qué es un Agregado?
Antes de entrar en detalles técnicos, entendamos el concepto con una analogía:
Imagina un documento de factura. La factura tiene un encabezado (cliente, fecha) y líneas de items. No tiene sentido modificar una línea de item sin considerar la factura completa: el total debe recalcularse, las validaciones deben aplicarse. La factura y sus items forman una unidad lógica que siempre se modifica junta. Eso es un agregado.
Un agregado es una frontera de consistencia transaccional. Todo lo que está dentro del agregado se modifica atómicamente.
graph TD
subgraph "Agregado Order"
O[Order Root]
I1[OrderItem]
I2[OrderItem]
A[Address]
end
O --> I1
O --> I2
O --> A
C[Customer] -.->|referencia por ID| O
P[Product] -.->|referencia por ID| I1
P -.->|referencia por ID| I2
Reglas Fundamentales
- Una raíz por agregado: El agregado se accede solo a través de su raíz (Aggregate Root). No puedes acceder directamente a un OrderItem; debes hacerlo a través de Order.
- Consistencia interna: Las invariantes (reglas de negocio que siempre deben cumplirse) se mantienen dentro del agregado. Si un pedido no puede tener más de 10 items, el agregado lo valida.
- Referencias por ID: Los agregados se referencian entre sí solo por identidad (IDs), nunca por referencia directa. Un Order tiene
customerId, no una referencia al objeto Customer. - Transacciones atómicas: Un comando modifica un solo agregado. Si necesitas modificar varios agregados, usa eventos y eventual consistency.
Identificando Agregados
Análisis del Dominio de Pedidos
Sistema de Pedidos E-commerce
├── Order (Agregado)
│ ├── OrderId (raíz)
│ ├── CustomerId (referencia)
│ ├── Items[]
│ │ ├── ProductId (referencia)
│ │ ├── Quantity
│ │ └── Price
│ ├── ShippingAddress
│ ├── Status
│ └── Totals
│
├── Customer (Agregado)
│ ├── CustomerId (raíz)
│ ├── Email
│ ├── Name
│ └── Addresses[]
│
├── Product (Agregado)
│ ├── ProductId (raíz)
│ ├── Name
│ ├── SKU
│ └── Price
│
└── Inventory (Agregado)
├── ProductId (raíz)
├── AvailableStock
└── ReservedStock
Implementación del Agregado Order
Veamos cómo implementar un agregado en código. Hay varios patrones importantes:
- Constructor privado: El agregado se crea solo a través de factory methods (
create,fromEvents) uncommittedEvents: Array que almacena los eventos generados que aún no se han guardado- Método
apply: Registra un evento Y actualiza el estado interno - Método
when: Actualiza el estado interno basándose en un evento (usado tanto al aplicar como al rehidratar) - Factory
fromEvents: Reconstruye el agregado reproduciendo eventos históricos (rehidratación)
TypeScript
// src/domain/aggregates/order/order.ts
import { v4 as uuid } from 'uuid';
import { DomainEvent } from '../../events/base';
import {
OrderCreated,
OrderItemAdded,
OrderConfirmed,
OrderCancelled
} from '../../events/order-events';
type OrderStatus = 'draft' | 'confirmed' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
interface OrderItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}
interface Address {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
}
export class Order {
private _id: string;
private _customerId: string;
private _items: OrderItem[] = [];
private _shippingAddress: Address | null = null;
private _status: OrderStatus = 'draft';
private _version: number = 0;
private _uncommittedEvents: DomainEvent[] = [];
private constructor() {}
// Getters
get id(): string { return this._id; }
get status(): OrderStatus { return this._status; }
get items(): readonly OrderItem[] { return this._items; }
get version(): number { return this._version; }
get uncommittedEvents(): readonly DomainEvent[] {
return this._uncommittedEvents;
}
get total(): number {
return this._items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
}
// Factory method para crear nuevo pedido
static create(customerId: string): Order {
const order = new Order();
const orderId = uuid();
order.apply({
type: 'OrderCreated',
orderId,
customerId,
occurredAt: new Date()
});
return order;
}
// Factory method para rehidratar desde eventos
static fromEvents(events: DomainEvent[]): Order {
const order = new Order();
events.forEach(event => order.when(event));
order._version = events.length;
return order;
}
// Comandos (métodos que generan eventos)
addItem(
productId: string,
productName: string,
quantity: number,
unitPrice: number
): void {
this.assertDraft();
this.assertPositiveQuantity(quantity);
const existingItem = this._items.find(i => i.productId === productId);
if (existingItem) {
throw new Error(`Product ${productId} already in order. Use updateQuantity.`);
}
this.apply({
type: 'OrderItemAdded',
orderId: this._id,
productId,
productName,
quantity,
unitPrice,
occurredAt: new Date()
});
}
removeItem(productId: string): void {
this.assertDraft();
const item = this._items.find(i => i.productId === productId);
if (!item) {
throw new Error(`Product ${productId} not in order`);
}
this.apply({
type: 'OrderItemRemoved',
orderId: this._id,
productId,
occurredAt: new Date()
});
}
setShippingAddress(address: Address): void {
this.assertDraft();
this.validateAddress(address);
this.apply({
type: 'ShippingAddressSet',
orderId: this._id,
address,
occurredAt: new Date()
});
}
confirm(): void {
this.assertDraft();
if (this._items.length === 0) {
throw new Error('Cannot confirm empty order');
}
if (!this._shippingAddress) {
throw new Error('Shipping address required');
}
this.apply({
type: 'OrderConfirmed',
orderId: this._id,
total: this.total,
occurredAt: new Date()
});
}
cancel(reason: string): void {
if (this._status === 'cancelled') {
throw new Error('Order already cancelled');
}
if (this._status === 'shipped' || this._status === 'delivered') {
throw new Error('Cannot cancel shipped order');
}
this.apply({
type: 'OrderCancelled',
orderId: this._id,
reason,
occurredAt: new Date()
});
}
// Event handlers (when)
private when(event: DomainEvent): void {
switch (event.type) {
case 'OrderCreated':
this._id = event.orderId;
this._customerId = event.customerId;
this._status = 'draft';
break;
case 'OrderItemAdded':
this._items.push({
productId: event.productId,
productName: event.productName,
quantity: event.quantity,
unitPrice: event.unitPrice
});
break;
case 'OrderItemRemoved':
this._items = this._items.filter(i => i.productId !== event.productId);
break;
case 'ShippingAddressSet':
this._shippingAddress = event.address;
break;
case 'OrderConfirmed':
this._status = 'confirmed';
break;
case 'OrderCancelled':
this._status = 'cancelled';
break;
}
}
private apply(event: DomainEvent): void {
this._uncommittedEvents.push(event);
this.when(event);
}
clearUncommittedEvents(): void {
this._uncommittedEvents = [];
}
// Invariant guards
private assertDraft(): void {
if (this._status !== 'draft') {
throw new Error(`Cannot modify order in status: ${this._status}`);
}
}
private assertPositiveQuantity(quantity: number): void {
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
}
private validateAddress(address: Address): void {
if (!address.street || !address.city || !address.zipCode) {
throw new Error('Invalid address');
}
}
}
Go
// domain/aggregates/order/order.go
package order
import (
"errors"
"time"
"github.com/google/uuid"
)
type OrderStatus string
const (
StatusDraft OrderStatus = "draft"
StatusConfirmed OrderStatus = "confirmed"
StatusPaid OrderStatus = "paid"
StatusShipped OrderStatus = "shipped"
StatusCancelled OrderStatus = "cancelled"
)
type OrderItem struct {
ProductID string
ProductName string
Quantity int
UnitPrice float64
}
type Address struct {
Street string
City string
State string
ZipCode string
Country string
}
type Order struct {
id string
customerID string
items []OrderItem
shippingAddress *Address
status OrderStatus
version int
uncommitted []DomainEvent
}
// Factory: crear nuevo pedido
func Create(customerID string) *Order {
order := &Order{}
orderID := uuid.New().String()
order.apply(OrderCreated{
OrderID: orderID,
CustomerID: customerID,
OccurredAt: time.Now(),
})
return order
}
// Factory: rehidratar desde eventos
func FromEvents(events []DomainEvent) *Order {
order := &Order{}
for _, event := range events {
order.when(event)
}
order.version = len(events)
return order
}
// Getters
func (o *Order) ID() string { return o.id }
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) Version() int { return o.version }
func (o *Order) UncommittedEvents() []DomainEvent { return o.uncommitted }
func (o *Order) Total() float64 {
total := 0.0
for _, item := range o.items {
total += item.UnitPrice * float64(item.Quantity)
}
return total
}
// Commands
func (o *Order) AddItem(productID, productName string, qty int, price float64) error {
if err := o.assertDraft(); err != nil {
return err
}
if qty <= 0 {
return errors.New("quantity must be positive")
}
for _, item := range o.items {
if item.ProductID == productID {
return errors.New("product already in order")
}
}
o.apply(OrderItemAdded{
OrderID: o.id,
ProductID: productID,
ProductName: productName,
Quantity: qty,
UnitPrice: price,
OccurredAt: time.Now(),
})
return nil
}
func (o *Order) Confirm() error {
if err := o.assertDraft(); err != nil {
return err
}
if len(o.items) == 0 {
return errors.New("cannot confirm empty order")
}
if o.shippingAddress == nil {
return errors.New("shipping address required")
}
o.apply(OrderConfirmed{
OrderID: o.id,
Total: o.Total(),
OccurredAt: time.Now(),
})
return nil
}
func (o *Order) Cancel(reason string) error {
if o.status == StatusCancelled {
return errors.New("order already cancelled")
}
if o.status == StatusShipped {
return errors.New("cannot cancel shipped order")
}
o.apply(OrderCancelled{
OrderID: o.id,
Reason: reason,
OccurredAt: time.Now(),
})
return nil
}
// Event handlers
func (o *Order) when(event DomainEvent) {
switch e := event.(type) {
case OrderCreated:
o.id = e.OrderID
o.customerID = e.CustomerID
o.status = StatusDraft
case OrderItemAdded:
o.items = append(o.items, OrderItem{
ProductID: e.ProductID,
ProductName: e.ProductName,
Quantity: e.Quantity,
UnitPrice: e.UnitPrice,
})
case OrderConfirmed:
o.status = StatusConfirmed
case OrderCancelled:
o.status = StatusCancelled
}
}
func (o *Order) apply(event DomainEvent) {
o.uncommitted = append(o.uncommitted, event)
o.when(event)
}
func (o *Order) ClearUncommittedEvents() {
o.uncommitted = nil
}
func (o *Order) assertDraft() error {
if o.status != StatusDraft {
return errors.New("cannot modify order in status: " + string(o.status))
}
return nil
}
Python
# domain/aggregates/order/order.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import uuid4
class OrderStatus(Enum):
DRAFT = "draft"
CONFIRMED = "confirmed"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"
@dataclass
class OrderItem:
product_id: str
product_name: str
quantity: int
unit_price: float
@dataclass
class Address:
street: str
city: str
state: str
zip_code: str
country: str
class Order:
def __init__(self):
self._id: str = ""
self._customer_id: str = ""
self._items: list[OrderItem] = []
self._shipping_address: Optional[Address] = None
self._status: OrderStatus = OrderStatus.DRAFT
self._version: int = 0
self._uncommitted: list = []
@property
def id(self) -> str:
return self._id
@property
def status(self) -> OrderStatus:
return self._status
@property
def total(self) -> float:
return sum(item.unit_price * item.quantity for item in self._items)
@property
def uncommitted_events(self) -> list:
return self._uncommitted.copy()
@classmethod
def create(cls, customer_id: str) -> "Order":
order = cls()
order_id = str(uuid4())
order._apply({
"type": "OrderCreated",
"order_id": order_id,
"customer_id": customer_id,
"occurred_at": datetime.utcnow()
})
return order
@classmethod
def from_events(cls, events: list) -> "Order":
order = cls()
for event in events:
order._when(event)
order._version = len(events)
return order
def add_item(
self,
product_id: str,
product_name: str,
quantity: int,
unit_price: float
) -> None:
self._assert_draft()
if quantity <= 0:
raise ValueError("Quantity must be positive")
if any(i.product_id == product_id for i in self._items):
raise ValueError(f"Product {product_id} already in order")
self._apply({
"type": "OrderItemAdded",
"order_id": self._id,
"product_id": product_id,
"product_name": product_name,
"quantity": quantity,
"unit_price": unit_price,
"occurred_at": datetime.utcnow()
})
def confirm(self) -> None:
self._assert_draft()
if not self._items:
raise ValueError("Cannot confirm empty order")
if not self._shipping_address:
raise ValueError("Shipping address required")
self._apply({
"type": "OrderConfirmed",
"order_id": self._id,
"total": self.total,
"occurred_at": datetime.utcnow()
})
def cancel(self, reason: str) -> None:
if self._status == OrderStatus.CANCELLED:
raise ValueError("Order already cancelled")
if self._status == OrderStatus.SHIPPED:
raise ValueError("Cannot cancel shipped order")
self._apply({
"type": "OrderCancelled",
"order_id": self._id,
"reason": reason,
"occurred_at": datetime.utcnow()
})
def _when(self, event: dict) -> None:
match event["type"]:
case "OrderCreated":
self._id = event["order_id"]
self._customer_id = event["customer_id"]
self._status = OrderStatus.DRAFT
case "OrderItemAdded":
self._items.append(OrderItem(
product_id=event["product_id"],
product_name=event["product_name"],
quantity=event["quantity"],
unit_price=event["unit_price"]
))
case "OrderConfirmed":
self._status = OrderStatus.CONFIRMED
case "OrderCancelled":
self._status = OrderStatus.CANCELLED
def _apply(self, event: dict) -> None:
self._uncommitted.append(event)
self._when(event)
def clear_uncommitted_events(self) -> None:
self._uncommitted.clear()
def _assert_draft(self) -> None:
if self._status != OrderStatus.DRAFT:
raise ValueError(f"Cannot modify order in status: {self._status.value}")
Testing de Agregados
// tests/domain/order.test.ts
import { describe, it, expect } from 'vitest';
import { Order } from '../../src/domain/aggregates/order';
describe('Order Aggregate', () => {
describe('creation', () => {
it('should create order in draft status', () => {
const order = Order.create('customer-123');
expect(order.status).toBe('draft');
expect(order.items).toHaveLength(0);
expect(order.uncommittedEvents).toHaveLength(1);
expect(order.uncommittedEvents[0].type).toBe('OrderCreated');
});
});
describe('adding items', () => {
it('should add item to draft order', () => {
const order = Order.create('customer-123');
order.addItem('prod-1', 'Widget', 2, 29.99);
expect(order.items).toHaveLength(1);
expect(order.total).toBe(59.98);
});
it('should reject duplicate products', () => {
const order = Order.create('customer-123');
order.addItem('prod-1', 'Widget', 1, 29.99);
expect(() => order.addItem('prod-1', 'Widget', 1, 29.99))
.toThrow('already in order');
});
});
describe('confirmation', () => {
it('should confirm order with items and address', () => {
const order = Order.create('customer-123');
order.addItem('prod-1', 'Widget', 1, 29.99);
order.setShippingAddress({
street: '123 Main St',
city: 'Springfield',
state: 'IL',
zipCode: '62701',
country: 'US'
});
order.confirm();
expect(order.status).toBe('confirmed');
});
it('should reject empty order confirmation', () => {
const order = Order.create('customer-123');
expect(() => order.confirm()).toThrow('empty order');
});
});
describe('rehydration', () => {
it('should rebuild state from events', () => {
const events = [
{ type: 'OrderCreated', orderId: 'order-1', customerId: 'cust-1', occurredAt: new Date() },
{ type: 'OrderItemAdded', orderId: 'order-1', productId: 'p1', productName: 'Widget', quantity: 2, unitPrice: 10, occurredAt: new Date() },
{ type: 'OrderConfirmed', orderId: 'order-1', total: 20, occurredAt: new Date() }
];
const order = Order.fromEvents(events);
expect(order.id).toBe('order-1');
expect(order.status).toBe('confirmed');
expect(order.items).toHaveLength(1);
expect(order.version).toBe(3);
});
});
});
Resumen
- Los agregados son fronteras de consistencia transaccional
- Se acceden solo a través de la raíz del agregado
- Los métodos de comando validan invariantes y generan eventos
- Los handlers de eventos (when) actualizan el estado interno
- Los agregados se rehidratan reproduciendo eventos históricos
- Un comando = Un agregado = Una transacción
Glosario
Aggregate Root (Raiz del Agregado)
Definicion: La entidad principal de un agregado que sirve como unico punto de acceso. Todas las operaciones pasan a traves de ella.
Por que es importante: Garantiza que las reglas de negocio se validen consistentemente. Previene modificaciones directas a entidades internas que podrian dejar el agregado en estado invalido.
Ejemplo practico: Order es la raiz. Para agregar un item, llamas order.addItem(), no creas un OrderItem y lo insertas directamente. Order valida limites, calcula totales, y genera el evento correspondiente.
Invariante
Definicion: Regla de negocio que debe cumplirse siempre, sin importar que operaciones se realicen. El agregado es responsable de mantenerlas.
Por que es importante: Las invariantes protegen la integridad del dominio. Si se violan, el sistema esta en un estado corrupto.
Ejemplo practico: “Un pedido confirmado no puede modificarse”, “El total del pedido debe ser la suma de sus items”, “No se pueden agregar mas de 50 items a un pedido”. El agregado valida estas reglas antes de generar eventos.
Boundary (Frontera/Limite)
Definicion: El perimetro logico de un agregado que define que esta “dentro” (se modifica atomicamente) y que esta “fuera” (se referencia por ID).
Por que es importante: Definir buenos limites es crucial para el rendimiento y la consistencia. Agregados muy grandes son lentos de cargar; muy pequenos pueden violar consistencia.
Ejemplo practico: Un Order incluye sus OrderItems (dentro del boundary) pero referencia Customer solo por customerId (fuera del boundary). Puedes modificar items sin cargar el customer.
Rehidratacion (Rehydration)
Definicion: Proceso de reconstruir el estado de un agregado reproduciendo todos sus eventos historicos desde el Event Store.
Por que es importante: En Event Sourcing no guardamos el estado actual; lo calculamos. Cada vez que necesitas un agregado, lo “rehidratas” desde sus eventos.
Ejemplo practico: Order.fromEvents(events) recibe [OrderCreated, ItemAdded, ItemAdded, OrderConfirmed] y reproduce cada evento secuencialmente para llegar al estado actual (confirmed con 2 items).
Uncommitted Events (Eventos No Comprometidos)
Definicion: Eventos que el agregado ha generado durante la operacion actual pero que aun no se han persistido en el Event Store.
Por que es importante: Permite que el agregado genere multiples eventos en una operacion y luego guardarlos todos juntos de forma atomica.
Ejemplo practico: Al crear un pedido con 3 items, el agregado genera OrderCreated seguido de 3 OrderItemAdded. Estos 4 eventos estan en uncommittedEvents hasta que el repositorio los guarda.
Comando (Command)
Definicion: Intencion de modificar el sistema. Los comandos se ejecutan contra agregados y, si son validos, generan eventos.
Por que es importante: Los comandos expresan la intencion del usuario (imperativos: “Crear pedido”, “Cancelar pedido”), mientras los eventos expresan lo que ocurrio (pasado: “Pedido creado”, “Pedido cancelado”).
Ejemplo practico: CreateOrderCommand { customerId, items } es procesado por el agregado que valida y genera OrderCreated. El comando puede fallar (validacion); el evento ya ocurrio.
Factory Method
Definicion: Metodo estatico que crea instancias del agregado, encapsulando la logica de construccion.
Por que es importante: Permite tener constructores privados y multiples formas de crear agregados: uno para crear nuevos (create) y otro para rehidratar (fromEvents).
Ejemplo practico: Order.create(customerId, items) crea un nuevo pedido generando OrderCreated. Order.fromEvents(events) reconstruye un pedido existente sin generar nuevos eventos.
Transaccion Atomica
Definicion: Operacion que se completa totalmente o no se completa en absoluto. No hay estados intermedios visibles.
Por que es importante: Garantiza consistencia. Si agregas un item y actualizas el total, ambos cambios ocurren juntos o ninguno ocurre.
Ejemplo practico: Al confirmar un pedido, se genera OrderConfirmed y se persiste atomicamente. Si falla la persistencia, el evento no existe y el pedido sigue en draft.
← Capítulo 2: Eventos como Fuente de Verdad | Capítulo 4: Event Store - Conceptos →