Loading content...
Imagine an e-commerce system processing a flash sale. Thousands of customers simultaneously attempt to purchase from limited inventory. Each order must:
If you try to make all of these changes in a single atomic transaction, you'll face:
This scenario illustrates the fundamental tension in distributed systems: consistency versus scalability. Aggregates provide a principled solution by defining consistency boundaries—clear lines that separate what must be immediately consistent from what can be eventually consistent.
By the end of this page, you will understand how aggregates define consistency boundaries, the difference between immediate and eventual consistency, strategies for coordinating changes across aggregates, and how to design boundaries that balance correctness with scalability.
A consistency boundary is a line around a set of data that must be kept consistent at all times. Within the boundary, all changes are atomic—they all succeed or all fail. Outside the boundary, consistency is maintained through other mechanisms (events, sagas, compensation).
In DDD, the aggregate defines the consistency boundary.
This means:
One transaction = One aggregate. This is the golden rule of aggregate design. Modifying multiple aggregates in a single transaction creates coupling, reduces throughput, and increases contention. Instead, design for eventual consistency across aggregates.
Why This Matters
Consider the alternative—modifying multiple aggregates in one transaction:
// ❌ WRONG: Single transaction spanning multiple aggregates
transaction {
order.confirm() // Locks Order
inventory.reserve(items) // Locks Inventory
customer.addPoints(100) // Locks Customer
payment.authorize() // Locks Payment
}
This approach has severe problems:
Understanding the difference between immediate and eventual consistency is crucial for aggregate boundary design.
The Key Question: What MUST be Immediately Consistent?
This question drives aggregate boundary decisions. Consider each business rule and ask:
What is the cost of temporary inconsistency?
How long can we tolerate inconsistency?
Can we detect and compensate for inconsistency?
| Business Rule | Consistency Type | Rationale |
|---|---|---|
| Order total = sum of items | Immediate | Incorrect total would charge wrong amount |
| Account balance ≥ 0 | Immediate | Overdrafts have immediate financial impact |
| Inventory quantity ≥ 0 | Immediate | Overselling creates fulfillment problems |
| Customer loyalty points update | Eventual | Delay of seconds is imperceptible to user |
| Product view count increment | Eventual | Exact count at any moment doesn't matter |
| Search index update | Eventual | Brief lag in search results is acceptable |
| Email notification | Eventual | Email delivery is inherently asynchronous |
| Analytics events | Eventual | Analytics tolerates seconds of delay |
When in doubt, favor eventual consistency. Many 'must be immediate' requirements are actually just preferences. Challenge each requirement: 'What happens if there's a 5-second delay?' If the answer is 'nothing terrible,' eventual consistency is appropriate.
The aggregate boundary is perhaps the most important design decision in DDD. Get it wrong, and you'll face either:
The Decision Framework:
Example: Order Boundary Decision
Let's analyze what belongs in an Order aggregate:
| Candidate | Same Aggregate? | Reasoning |
|---|---|---|
| OrderLines | ✅ Yes | Order total invariant depends on lines |
| ShippingAddress | ✅ Yes | Required for order confirmation invariant |
| Customer | ❌ No | Independent lifecycle, shared across orders |
| Product | ❌ No | Shared across many orders, independent lifecycle |
| PaymentInfo | ⚠️ Depends | Could be internal (simple) or separate (complex payments) |
| OrderHistory | ❌ No | Append-only log, no invariants with order |
The key insight: OrderLines must be in the same aggregate as Order because the invariant "order total = sum of line totals" requires immediate consistency.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// ============================================// EXAMPLE: E-Commerce Domain Aggregates// ============================================ // Order Aggregate - defines the transaction/consistency boundary// Includes: Order, OrderLines, ShippingAddress (value object)class Order { private readonly _lines: Map<string, OrderLine> = new Map(); private _shippingAddress: Address; private _status: OrderStatus; // Reference by ID to other aggregates private readonly _customerId: string; // Lines are INSIDE the boundary - immediate consistency addLine(productId: string, name: string, price: Money, qty: number): void { // Invariant checked immediately if (this._lines.size >= 50) { throw new Error("Maximum 50 items per order"); } const line = OrderLine.create(/*...*/); this._lines.set(line.lineId, line); // Total is always consistent with lines (computed) } // Total invariant: enforced by computation, not storage calculateTotal(): Money { return Array.from(this._lines.values()) .reduce((sum, line) => sum.add(line.lineTotal), Money.zero('USD')); }} // Product Aggregate - OUTSIDE Order's boundary// Order references products by ID onlyclass Product { private readonly _productId: string; private _name: string; private _price: Money; private _stockQuantity: number; // Stock quantity is Product's consistency concern // Order can't directly modify this reserveStock(quantity: number): StockReservation { if (this._stockQuantity < quantity) { throw new Error("Insufficient stock"); } this._stockQuantity -= quantity; return new StockReservation(this._productId, quantity); } releaseStock(quantity: number): void { this._stockQuantity += quantity; }} // Customer Aggregate - OUTSIDE Order's boundary// Loyalty points are Customer's consistency concernclass Customer { private readonly _customerId: string; private _loyaltyPoints: number = 0; private _tier: CustomerTier = 'standard'; // Points update is eventually consistent with order addLoyaltyPoints(points: number, orderId: string): void { this._loyaltyPoints += points; this.evaluateTierUpgrade(); } private evaluateTierUpgrade(): void { if (this._loyaltyPoints >= 10000 && this._tier === 'standard') { this._tier = 'gold'; } }} // ============================================// CROSS-AGGREGATE COORDINATION via Events// ============================================ // When order is confirmed, event is publishedclass OrderConfirmedEvent { constructor( readonly orderId: string, readonly customerId: string, readonly items: Array<{productId: string; quantity: number}>, readonly totalAmount: Money, readonly pointsToAward: number ) {}} // Event handler updates customer aggregate (eventually consistent)class CustomerLoyaltyHandler { constructor(private readonly customerRepo: CustomerRepository) {} async handle(event: OrderConfirmedEvent): Promise<void> { const customer = await this.customerRepo.findById(event.customerId); customer.addLoyaltyPoints(event.pointsToAward, event.orderId); await this.customerRepo.save(customer); }} // Event handler updates inventory (eventually consistent)class InventoryReservationHandler { constructor(private readonly productRepo: ProductRepository) {} async handle(event: OrderConfirmedEvent): Promise<void> { for (const item of event.items) { const product = await this.productRepo.findById(item.productId); product.reserveStock(item.quantity); await this.productRepo.save(product); } }}When operations span multiple aggregates, you need coordination strategies. Here are the primary patterns, from simplest to most complex:
Pattern 1: Domain Events (Most Common)
The simplest approach: one aggregate makes its change, publishes an event, and other aggregates react.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Step 1: Order confirms and publishes eventclass OrderApplicationService { constructor( private readonly orderRepo: OrderRepository, private readonly eventPublisher: DomainEventPublisher ) {} async confirmOrder(orderId: string): Promise<void> { const order = await this.orderRepo.findById(orderId); order.confirm(); // This adds OrderConfirmedEvent to order's events await this.orderRepo.save(order); // Persists order // Publish events after successful persistence for (const event of order.getDomainEvents()) { await this.eventPublisher.publish(event); } order.clearDomainEvents(); }} // Step 2: Handlers react to event (eventually consistent)class InventoryEventHandler { async handleOrderConfirmed(event: OrderConfirmedEvent): Promise<void> { for (const item of event.items) { const product = await this.productRepo.findById(item.productId); product.reserveStock(item.quantity); await this.productRepo.save(product); } }} class CustomerEventHandler { async handleOrderConfirmed(event: OrderConfirmedEvent): Promise<void> { const customer = await this.customerRepo.findById(event.customerId); customer.addLoyaltyPoints(event.pointsToAward); await this.customerRepo.save(customer); }} class NotificationEventHandler { async handleOrderConfirmed(event: OrderConfirmedEvent): Promise<void> { await this.emailService.sendOrderConfirmation( event.customerId, event.orderId, event.totalAmount ); }}Pattern 2: Saga / Process Manager
For complex multi-step processes that need coordination and failure handling:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
// Saga tracks the state of a multi-aggregate processtype OrderSagaState = | 'started' | 'payment_authorized' | 'inventory_reserved' | 'completed' | 'compensating' | 'failed'; class OrderProcessingSaga { private _state: OrderSagaState = 'started'; private _paymentId: string | null = null; private _reservationId: string | null = null; constructor( private readonly orderId: string, private readonly customerId: string, private readonly items: OrderItemDTO[] ) {} get state(): OrderSagaState { return this._state; } // Each step transitions state and returns next action onPaymentAuthorized(paymentId: string): SagaAction { if (this._state !== 'started') { throw new Error(`Invalid state transition from ${this._state}`); } this._paymentId = paymentId; this._state = 'payment_authorized'; // Next step: reserve inventory return { type: 'reserve_inventory', orderId: this.orderId, items: this.items }; } onInventoryReserved(reservationId: string): SagaAction { if (this._state !== 'payment_authorized') { throw new Error(`Invalid state transition from ${this._state}`); } this._reservationId = reservationId; this._state = 'completed'; // Final step: confirm order return { type: 'confirm_order', orderId: this.orderId }; } // Handle failures with compensation onInventoryReservationFailed(reason: string): SagaAction { if (this._state !== 'payment_authorized') { throw new Error(`Invalid state transition from ${this._state}`); } this._state = 'compensating'; // Compensate: release payment authorization return { type: 'release_payment', paymentId: this._paymentId!, reason: `Inventory unavailable: ${reason}` }; } onPaymentReleased(): SagaAction { this._state = 'failed'; return { type: 'fail_order', orderId: this.orderId, reason: 'Inventory unavailable' }; }} // Saga Orchestrator coordinates the processclass OrderSagaOrchestrator { constructor( private readonly sagaRepo: SagaRepository, private readonly paymentService: PaymentService, private readonly inventoryService: InventoryService, private readonly orderService: OrderService ) {} async startSaga(order: OrderDTO): Promise<void> { const saga = new OrderProcessingSaga( order.orderId, order.customerId, order.items ); await this.sagaRepo.save(saga); // Start with payment authorization await this.paymentService.authorizePayment( order.orderId, order.totalAmount ); } async handlePaymentAuthorized( orderId: string, paymentId: string ): Promise<void> { const saga = await this.sagaRepo.findByOrderId(orderId); const action = saga.onPaymentAuthorized(paymentId); await this.sagaRepo.save(saga); await this.executeAction(action); } async handleInventoryReserved( orderId: string, reservationId: string ): Promise<void> { const saga = await this.sagaRepo.findByOrderId(orderId); const action = saga.onInventoryReserved(reservationId); await this.sagaRepo.save(saga); await this.executeAction(action); } async handleInventoryReservationFailed( orderId: string, reason: string ): Promise<void> { const saga = await this.sagaRepo.findByOrderId(orderId); const action = saga.onInventoryReservationFailed(reason); await this.sagaRepo.save(saga); await this.executeAction(action); } private async executeAction(action: SagaAction): Promise<void> { switch (action.type) { case 'reserve_inventory': await this.inventoryService.reserveInventory( action.orderId, action.items ); break; case 'confirm_order': await this.orderService.confirmOrder(action.orderId); break; case 'release_payment': await this.paymentService.releaseAuthorization( action.paymentId, action.reason ); break; case 'fail_order': await this.orderService.failOrder( action.orderId, action.reason ); break; } }}Use simple domain events when: steps are independent, failure of one doesn't require undoing others, order doesn't matter. Use sagas when: steps must complete in order, failure requires compensation, you need to track the overall process state.
Even with well-designed boundaries, conflicts can occur. Two users might try to modify the same aggregate simultaneously, or eventual consistency might lead to temporary inconsistencies. Here are strategies to handle these situations:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// Aggregate with version for optimistic concurrencyclass Order { private _version: number = 0; constructor( private readonly _orderId: string, private _status: OrderStatus, // ... other fields ) {} get version(): number { return this._version; } confirm(): void { if (this._status !== 'draft') { throw new Error(`Cannot confirm ${this._status} order`); } this._status = 'confirmed'; this._version++; // Increment on change } _setVersion(version: number): void { this._version = version; }} // Repository enforces version check on saveclass OrderRepository { async save(order: Order): Promise<void> { const result = await this.db.query(` UPDATE orders SET status = $2, version = version + 1 WHERE id = $1 AND version = $3 RETURNING version `, [order.orderId, order.status, order.version - 1]); if (result.rowCount === 0) { throw new OptimisticConcurrencyError( `Order ${order.orderId} was modified by another process` ); } }} // Application service handles retryclass OrderService { async confirmOrderWithRetry( orderId: string, maxRetries: number = 3 ): Promise<void> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const order = await this.orderRepo.findById(orderId); order.confirm(); await this.orderRepo.save(order); return; // Success } catch (error) { if (error instanceof OptimisticConcurrencyError) { if (attempt === maxRetries) { throw new Error( `Failed to confirm order after ${maxRetries} attempts` ); } // Wait briefly before retry await this.sleep(100 * attempt); continue; } throw error; // Different error, don't retry } } } private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }} class OptimisticConcurrencyError extends Error { constructor(message: string) { super(message); this.name = 'OptimisticConcurrencyError'; }}Handling Eventual Consistency Failures
When eventual consistency handlers fail, you need strategies to ensure the system eventually reaches a consistent state:
123456789101112131415161718192021222324252627282930313233343536373839404142
// Idempotent handler - safe to run multiple timesclass LoyaltyPointsHandler { constructor( private readonly customerRepo: CustomerRepository, private readonly processedEventsRepo: ProcessedEventsRepository ) {} async handle(event: OrderConfirmedEvent): Promise<void> { // Check if already processed (idempotency key) const alreadyProcessed = await this.processedEventsRepo.exists( event.eventId ); if (alreadyProcessed) { console.log(`Event ${event.eventId} already processed, skipping`); return; } // Process the event const customer = await this.customerRepo.findById(event.customerId); customer.addLoyaltyPoints(event.pointsToAward, event.orderId); await this.customerRepo.save(customer); // Mark as processed await this.processedEventsRepo.markProcessed(event.eventId); }} // Alternative: Idempotency at aggregate levelclass Customer { private readonly _processedOrders: Set<string> = new Set(); addLoyaltyPoints(points: number, orderId: string): void { // Idempotency check at domain level if (this._processedOrders.has(orderId)) { return; // Already awarded points for this order } this._loyaltyPoints += points; this._processedOrders.add(orderId); }}Aggregate boundary decisions involve fundamental trade-offs. Understanding these helps you make informed choices:
| Aspect | Larger Aggregates | Smaller Aggregates |
|---|---|---|
| Consistency | More invariants enforced immediately | Some invariants require eventual consistency |
| Concurrency | Higher contention, lower throughput | Lower contention, higher throughput |
| Loading cost | More data loaded per operation | Less data loaded per operation |
| Complexity | Simpler invariant enforcement | More complex cross-aggregate coordination |
| Scaling | Harder to distribute | Easier to distribute across nodes |
| Transactions | Longer, more locks held | Shorter, fewer locks |
It's easier to combine small aggregates than to split large ones. When unsure, start with smaller aggregates and combine only when you discover that invariants truly require immediate consistency across them.
Practical Guidelines for Boundary Design:
We've explored how aggregates define consistency boundaries. Let's consolidate the key points:
What's Next:
In the final page of this module, we'll explore Aggregate Design Rules—the practical guidelines and patterns for designing aggregates that are maintainable, performant, and correctly model your domain.
You now understand how aggregates define consistency boundaries. The aggregate is the unit of immediate consistency; cross-aggregate consistency is achieved eventually through events. This design enables both correctness and scalability—the holy grail of distributed systems.