Domain-Driven Design
02 / 02

Tactical Design: Entities, Aggregates & Repositories

Domain-Driven Design: Tactical Design

Entities vs Value Objects

// Entity: has persistent identity independent of its attributes.
// Two Customers with identical current attributes are still
// DIFFERENT customers if their IDs differ.
class Customer {
  constructor(readonly id: CustomerId, private name: string) {}
  rename(newName: string) { this.name = newName; } // identity persists through change
}

// Value Object: defined ENTIRELY by its attribute values, no
// identity of its own -- typically immutable, interchangeable
class Money {
  constructor(readonly amount: number, readonly currency: string) {}
  equals(other: Money) {
    return this.amount === other.amount && this.currency === other.currency;
  }
  add(other: Money): Money {
    return new Money(this.amount + other.amount, this.currency); // returns NEW instance
  }
}

Aggregates: the Consistency Boundary

// Order is the Aggregate Root -- the ONLY entry point external
// code may reference directly. OrderLine is only reachable through it.
class Order {
  private lines: OrderLine[] = [];
  private status: 'draft' | 'shipped' = 'draft';

  addLine(product: ProductId, quantity: number, price: Money) {
    if (this.status === 'shipped') {
      throw new Error('Cannot modify a shipped order'); // invariant enforced HERE
    }
    this.lines.push(new OrderLine(product, quantity, price));
  }

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

  ship() {
    if (this.lines.length === 0) throw new Error('Cannot ship an empty order');
    this.status = 'shipped';
  }
}

// Cross-aggregate references by ID, not object reference -- keeps
// Order's transaction independent of Customer's
class Order {
  constructor(readonly customerId: CustomerId) {} // not a full Customer object
}

Repositories, Domain Services & Domain Events

// Repository: collection-like interface, hides persistence details
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// Domain Service: an operation spanning multiple Aggregates that
// doesn't naturally belong to just one of them
class TransferService {
  transfer(from: Account, to: Account, amount: Money) {
    from.withdraw(amount);
    to.deposit(amount);
  }
}

// Domain Event: something significant that happened -- lets other
// parts of the system react without direct coupling
class OrderShipped {
  constructor(readonly orderId: OrderId, readonly shippedAt: Date) {}
}

class Order {
  ship() {
    this.status = 'shipped';
    this.recordEvent(new OrderShipped(this.id, new Date()));
  }
}

When the Tactical Toolkit Is (and Isn't) Worth It

  • Value is proportional to actual domain complexity -- real invariants, business rules worth enforcing.

  • A simple CRUD app with minimal business rules gets little benefit from the full Aggregate/Repository/Domain Service toolkit -- can be over-engineering relative to what's needed.

  • Factory pattern: encapsulates complex creation logic when a plain constructor would risk producing an invalid intermediate object state.

  • Specification pattern: names and composes a business rule (e.g. IsEligibleForDiscountSpecification) as a reusable object instead of a scattered inline conditional.

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free