# 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.
