References

Aggregate

Concept

An Aggregate is a cluster of related domain objects (Entities and Value Objects) treated as a single unit for the purpose of data changes. One Entity within the cluster is designated the Aggregate Root — the only entry point for all external interactions.

Aggregates define consistency boundaries: all invariants within the aggregate are guaranteed after every operation. Persistence is always transactional at the aggregate level — the entire aggregate is saved or none of it is.


Key Characteristics

  • Aggregate Root — one Entity controls access to all other objects in the cluster
  • Consistency boundary — all invariants hold within one aggregate after every operation
  • Single transaction — save the entire aggregate atomically; never partial saves
  • External references by ID only — other aggregates hold only the ID of this root, never a direct object reference
  • Small by design — prefer smaller aggregates to reduce contention and improve concurrency

When to Use

Design an Aggregate when:

  • Multiple objects must maintain invariants together (Order must always have a consistent total across its items)
  • A group of objects changes together atomically (adding/removing OrderItems must stay consistent with Order status)
  • You need a clear boundary for who owns what data

Keep aggregates small:

  • If an aggregate has 10+ child objects, it’s likely too large — consider splitting
  • Each aggregate root should map to one save() call in a repository
  • Contention on a large aggregate blocks all operations on it (concurrency problem)

Core Patterns

// domain/aggregates/Order.ts — Aggregate Root
import { Money } from '../value-objects/Money';
import { OrderItem } from './OrderItem';
import { OrderConfirmed } from '../events/OrderConfirmed';

export type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'cancelled';

export class Order {                           // Aggregate Root
  private _items: OrderItem[] = [];
  private _status: OrderStatus = 'draft';
  private _events: unknown[] = [];

  constructor(
    readonly id: string,
    readonly customerId: string,
  ) {}

  // ─── Read-only access to children ────────────────────────────────────────
  get items(): readonly OrderItem[] { return this._items; }
  get status(): OrderStatus         { return this._status; }

  get total(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.subtotal),
      new Money(0, 'USD'),
    );
  }

  // ─── Controlled mutation through root ────────────────────────────────────
  addItem(productId: string, price: Money, quantity: number): void {
    if (this._status !== 'draft') {
      throw new Error('Cannot modify a confirmed order');
    }
    const existing = this._items.find(i => i.productId === productId);
    if (existing) {
      existing.increaseQuantity(quantity);  // delegate to child entity
    } else {
      this._items.push(new OrderItem(productId, price, quantity));
    }
  }

  removeItem(productId: string): void {
    if (this._status !== 'draft') throw new Error('Cannot modify a confirmed order');
    this._items = this._items.filter(i => i.productId !== productId);
  }

  // ─── Invariant enforcement ────────────────────────────────────────────────
  confirm(): void {
    if (this._items.length === 0) throw new Error('Cannot confirm an empty order');
    if (this._status !== 'draft') throw new Error('Order is already confirmed');
    this._status = 'confirmed';
    this._events.push(new OrderConfirmed(this.id, this.customerId, this.total, new Date()));
  }

  cancel(): void {
    if (this._status === 'shipped') throw new Error('Cannot cancel a shipped order');
    this._status = 'cancelled';
  }

  // ─── Domain events ────────────────────────────────────────────────────────
  pullEvents(): unknown[] {
    const events = [...this._events];
    this._events = [];
    return events;
  }
}

// domain/aggregates/OrderItem.ts — child entity, only accessible via Order
export class OrderItem {
  constructor(
    readonly productId: string,
    readonly price: Money,
    private _quantity: number,
  ) {
    if (_quantity <= 0) throw new Error('Quantity must be positive');
  }

  get quantity(): number { return this._quantity; }
  get subtotal(): Money  { return this.price.multiply(this._quantity); }

  increaseQuantity(amount: number): void {
    if (amount <= 0) throw new Error('Amount must be positive');
    this._quantity += amount;
  }
}

Aggregate Design Rules

External references by ID, not object:

// WRONG: holding a reference to another aggregate
class Order {
  customer: Customer; // tight coupling — two aggregates in one transaction
}

// CORRECT: hold only the ID
class Order {
  customerId: string; // reference by ID; load Customer separately if needed
}

Never reach into a child directly:

// WRONG: bypassing the aggregate root
order.items.push(new OrderItem(...)); // circumvents business rules

// CORRECT: always go through the root
order.addItem(productId, price, quantity); // root enforces all rules

One aggregate = one transaction:

// WRONG: saving two aggregates in one transaction
await db.$transaction(async (tx) => {
  await orderRepo.save(order, tx);
  await inventoryRepo.save(inventory, tx); // different aggregate!
});

// CORRECT: eventual consistency via domain events
// Order publishes OrderConfirmed → Inventory subscribes and reserves stock

Common Mistakes

Too-large aggregates — everything in one root causes lock contention. If save(order) locks 500 items, every concurrent order operation blocks. Design around business transaction boundaries, not data convenience.

Too-small aggregates — splitting invariants across two aggregates makes them impossible to enforce transactionally. If Order and OrderItems must always be consistent, they belong in the same aggregate.

Exposing mutable collections:

// WRONG: returns mutable array
get items(): OrderItem[] { return this._items; }

// CORRECT: returns readonly view
get items(): readonly OrderItem[] { return this._items; }