# Aggregate Pattern ## Problem In a complex domain, multiple related objects must be kept consistent. An `Order` with `LineItems` must maintain invariants like "total equals sum of line items" and "can't remove the last item." Without clear boundaries, any code can modify any object, leading to inconsistent state and hard-to-find bugs. ## Solution Define an **aggregate** — a cluster of related domain objects treated as a single unit for data changes. The aggregate has: - **Aggregate root** — The single entry point. All external access goes through the root. - **Invariants** — Business rules the aggregate enforces internally. - **Transactional boundary** — The entire aggregate is loaded and saved as one unit. External code never reaches inside the aggregate to modify child objects directly. ## When to Use - Domain-Driven Design with complex domain models - When multiple objects must maintain consistency invariants - When you need clear transactional boundaries - Order + LineItems, Account + Transactions, Cart + Items ## When to Avoid - Simple CRUD without business invariants - Read-heavy systems where aggregates add unnecessary indirection - When objects don't have meaningful consistency rules - Reporting/analytics queries (use read models instead) ## Pseudocode ``` class Order (Aggregate Root): items: list[LineItem] function add_item(product, quantity, price): enforce invariants items.add(LineItem(product, quantity, price)) recalculate_total() function remove_item(product): enforce: items.length > 1 items.remove_by(product) recalculate_total() # External code cannot directly modify items ``` ## Python Implementation ```python from dataclasses import dataclass, field from typing import Optional @dataclass(frozen=True) class Money: amount: float currency: str = "USD" def add(self, other: "Money") -> "Money": return Money(round(self.amount + other.amount, 2), self.currency) def multiply(self, n: int) -> "Money": return Money(round(self.amount * n, 2), self.currency) def __str__(self): return f"${self.amount:.2f}" @dataclass class LineItem: product: str quantity: int unit_price: Money @property def subtotal(self) -> Money: return self.unit_price.multiply(self.quantity) def __repr__(self): return f"LineItem({self.product!r}, qty={self.quantity}, subtotal={self.subtotal})" class OrderError(Exception): pass class Order: """Aggregate root — all modifications go through this class.""" def __init__(self, order_id: str, customer: str): self.id = order_id self.customer = customer self._items: list[LineItem] = [] self._status = "draft" def add_item(self, product: str, quantity: int, unit_price: Money) -> None: if self._status != "draft": raise OrderError(f"Cannot modify order in '{self._status}' status") if quantity <= 0: raise OrderError("Quantity must be positive") # Check if product already exists for item in self._items: if item.product == product: item.quantity += quantity return self._items.append(LineItem(product, quantity, unit_price)) def remove_item(self, product: str) -> None: if self._status != "draft": raise OrderError(f"Cannot modify order in '{self._status}' status") matching = [i for i in self._items if i.product == product] if not matching: raise OrderError(f"Product '{product}' not in order") self._items = [i for i in self._items if i.product != product] def place(self) -> None: if not self._items: raise OrderError("Cannot place empty order") if self._status != "draft": raise OrderError(f"Cannot place order in '{self._status}' status") self._status = "placed" @property def total(self) -> Money: result = Money(0) for item in self._items: result = result.add(item.subtotal) return result @property def item_count(self) -> int: return len(self._items) @property def status(self) -> str: return self._status @property def items(self) -> tuple: """Read-only view of items.""" return tuple(self._items) def __repr__(self): return (f"Order(id={self.id!r}, customer={self.customer!r}, " f"items={self.item_count}, total={self.total}, status={self._status!r})") # --- Demo --- print("=== Build order through aggregate root ===") order = Order("ORD-001", "Alice") order.add_item("Widget", 2, Money(9.99)) order.add_item("Gadget", 1, Money(24.99)) print(f" {order}") print(f" Items: {order.items}") print() print("=== Aggregate enforces invariants ===") # Add more of existing product (aggregate merges) order.add_item("Widget", 3, Money(9.99)) print(f" After adding 3 more Widgets: qty is now {order.items[0].quantity}") print(f" Total: {order.total}") print() print("=== Place order ===") order.place() print(f" Status: {order.status}") # Try to modify placed order try: order.add_item("Doohickey", 1, Money(5.00)) except OrderError as e: print(f" Modification blocked: {e}") print() print("=== Invariant: can't place empty order ===") empty = Order("ORD-002", "Bob") try: empty.place() except OrderError as e: print(f" Blocked: {e}") print() print("=== Invariant: quantity must be positive ===") draft = Order("ORD-003", "Charlie") try: draft.add_item("Thing", -1, Money(10.00)) except OrderError as e: print(f" Blocked: {e}") ``` **Output:** ``` === Build order through aggregate root === Order(id='ORD-001', customer='Alice', items=2, total=$44.97, status='draft') Items: (LineItem('Widget', qty=2, subtotal=$19.98), LineItem('Gadget', qty=1, subtotal=$24.99)) === Aggregate enforces invariants === After adding 3 more Widgets: qty is now 5 Total: $74.94 === Place order === Status: placed Modification blocked: Cannot modify order in 'placed' status === Invariant: can't place empty order === Blocked: Cannot place empty order === Invariant: quantity must be positive === Blocked: Quantity must be positive ``` ## JavaScript Implementation ```javascript class Money { constructor(amount, currency = "USD") { this.amount = Math.round(amount * 100) / 100; this.currency = currency; Object.freeze(this); } add(other) { return new Money(this.amount + other.amount, this.currency); } multiply(n) { return new Money(this.amount * n, this.currency); } toString() { return `$${this.amount.toFixed(2)}`; } } class LineItem { constructor(product, quantity, unitPrice) { this.product = product; this.quantity = quantity; this.unitPrice = unitPrice; } get subtotal() { return this.unitPrice.multiply(this.quantity); } toString() { return `LineItem(${this.product}, qty=${this.quantity}, subtotal=${this.subtotal})`; } } class OrderError extends Error {} class Order { #items = []; #status = "draft"; constructor(id, customer) { this.id = id; this.customer = customer; } addItem(product, quantity, unitPrice) { if (this.#status !== "draft") throw new OrderError(`Cannot modify '${this.#status}' order`); if (quantity <= 0) throw new OrderError("Quantity must be positive"); const existing = this.#items.find((i) => i.product === product); if (existing) { existing.quantity += quantity; return; } this.#items.push(new LineItem(product, quantity, unitPrice)); } place() { if (this.#items.length === 0) throw new OrderError("Cannot place empty order"); if (this.#status !== "draft") throw new OrderError(`Cannot place '${this.#status}' order`); this.#status = "placed"; } get total() { return this.#items.reduce((sum, i) => sum.add(i.subtotal), new Money(0)); } get status() { return this.#status; } get items() { return [...this.#items]; } toString() { return `Order(id=${this.id}, items=${this.#items.length}, total=${this.total}, status=${this.#status})`; } } // --- Demo --- console.log("=== Build order ==="); const order = new Order("ORD-001", "Alice"); order.addItem("Widget", 2, new Money(9.99)); order.addItem("Gadget", 1, new Money(24.99)); console.log(` ${order}`); order.addItem("Widget", 3, new Money(9.99)); console.log(` After adding 3 more Widgets: total=${order.total}`); order.place(); console.log(` Status: ${order.status}`); try { order.addItem("X", 1, new Money(1)); } catch (e) { console.log(` Blocked: ${e.message}`); } console.log("\n=== Empty order ==="); try { new Order("ORD-002", "Bob").place(); } catch (e) { console.log(` Blocked: ${e.message}`); } ``` ## Related Patterns - **Repository** — repositories load and store entire aggregates as atomic units; no partial aggregate loading. - **Value Object** — aggregates are composed of entities and value objects; value objects carry the immutable attributes. - **Guard Clause** — aggregates enforce invariants using guard clauses inside their mutating methods. - **Domain Events (Observer)** — aggregates raise domain events (observer pattern) when significant state changes occur.