Skip to content

DDD Tactical Patterns#

Problem statement (interviewer prompt)

Model the domain of an e-commerce order: entities (Order, OrderLine), value objects (Money, Address), aggregate roots, domain events (OrderPlaced, OrderShipped), and bounded contexts (Billing vs Shipping vs Inventory). Show the class diagram + state machine.

classDiagram
  class Aggregate {
    <<root>>
    +id
    +invariants enforced
  }
  class Entity {
    +id
    +mutable, lifecycle
  }
  class ValueObject {
    +immutable
    +equality by value
  }
  class DomainEvent {
    +past-tense fact
  }
  Aggregate o--> Entity
  Aggregate o--> ValueObject
  Aggregate ..> DomainEvent

DDD tactical building blocks model a single bounded context: aggregates enforce invariants, entities have identity and lifecycle, value objects are immutable values, domain events record what happened.

The four core types#

classDiagram
  class Order {
    <<aggregate root>>
    -id: OrderId
    -customerId: CustomerId
    -lines: List~OrderLine~
    -status: OrderStatus
    -total: Money
    +addLine(item, qty, price)
    +pay()
    +cancel()
  }
  class OrderLine {
    <<entity>>
    -lineId
    -item: ItemId
    -qty: int
    -unitPrice: Money
  }
  class Money {
    <<value object>>
    +amount
    +currency
  }
  class OrderPlaced {
    <<domain event>>
    +orderId
    +customerId
    +total
    +occurredAt
  }
  Order o--> OrderLine
  OrderLine --> Money
  Order ..> OrderPlaced

Aggregate#

A cluster of objects treated as one consistency boundary.

  • One aggregate root (here Order) is the only entry point.
  • External code references the root only by id.
  • All invariants must be valid after every public method.
  • One DB transaction per aggregate change.
class Order {
  void addLine(ItemId item, int qty, Money unitPrice) {
    if (status != DRAFT) throw new InvalidStateException();
    if (qty <= 0)        throw new IllegalArgumentException();
    lines.add(new OrderLine(item, qty, unitPrice));
    recompute();         // invariant: total = sum(lines)
  }
}

Entity#

Has identity that persists through state changes. Two entities with the same id are equal, regardless of attributes.

  • Order (root) and OrderLine (inside the aggregate) are entities.
  • Equality by id, not by value.

Value Object#

Immutable, identified by attributes. Two Money(10, "USD") are interchangeable.

record Money(BigDecimal amount, String currency) {
  Money add(Money other) {
    if (!currency.equals(other.currency)) throw new CurrencyMismatch();
    return new Money(amount.add(other.amount), currency);
  }
}

Why prefer value objects: - No accidental aliasing. - Self-validating (constructor enforces invariants). - Side-effect-free operations. - Free thread-safety.

Domain Event#

A past-tense fact the domain emits when something significant happens.

class OrderPlaced {
  final OrderId orderId;
  final CustomerId customerId;
  final Money total;
  final Instant occurredAt;
}
  • Emitted from the aggregate root.
  • Dispatched after the UoW commits (or via outbox).
  • Decoupled from external side-effects (email, inventory, analytics).

Domain Service#

Stateless operation that doesn't naturally belong on a single aggregate.

class PricingService {
  Money quote(Cart cart, Customer c, Promotion p) { ... }
}

Use sparingly - most logic belongs on the entity / aggregate, not in services.

Bounded Context#

The boundary in which a model has one meaning. "Customer" in the Billing context is not the same shape as "Customer" in Shipping. Cross-context links are anti-corruption layers, not shared classes.

flowchart LR
  subgraph Billing
    B[Customer billing model]
  end
  subgraph Shipping
    S[Customer shipping model]
  end
  subgraph Marketing
    M[Customer marketing model]
  end
  Billing -.events.-> Shipping
  Billing -.events.-> Marketing

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
    classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
    classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
    classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
    class B,S,M client;

Repository (for aggregates)#

One repository per aggregate root. See repository-pattern.

Putting it together: a use case#

class PlaceOrder {
  PlaceOrder(OrderRepository orders, EventBus events) { ... }

  Result place(PlaceOrderCmd cmd) {
    var order = new Order(cmd.customerId());
    for (var line : cmd.lines()) order.addLine(line.item, line.qty, line.price);
    orders.save(order);
    events.publish(new OrderPlaced(order.id(), ...));
    return Result.ok(order.id());
  }
}

Only the aggregate enforces business rules. The use case is a thin coordinator.

Anti-patterns#

  • Anaemic domain - entities are bags of getters/setters with no behaviour.
  • Aggregates referencing other aggregates by object - should be by id only.
  • Cross-aggregate transactions - use sagas instead.
  • Generic "BaseEntity" with everything in it - over-abstracted.
  • "Customer" used across every context - bounded contexts exist for a reason.

Where this shows up#

  • Every LLD-flavoured problem (Parking Lot, Hotel, Library, ATM) naturally maps to aggregates.
  • Banking ledger, payment gateway - aggregates with strong invariants.
  • E-commerce orders, bookings.

Glossary & fundamentals#

Concepts referenced in this design. Each row links to its canonical page; the tag column shows whether it is a high-level (HLD) or low-level (LLD) concept.

Tag Concept What it is Page
HLD Observability metrics, logs, traces, SLOs observability
HLD Event sourcing + CQRS commands -> events; separate read model event-sourcing-cqrs
LLD DDD tactical entity / value object / aggregate / event ddd-tactical
LLD Immutability immutable types, persistent collections immutability
LLD Error handling exceptions vs Result, error boundaries error-handling

Quick reference#

Sizing aggregates#

  • Small. If save() writes 100 rows, the aggregate is too big.
  • "Cluster around the invariant" - what must be transactionally consistent?
  • Cross-aggregate references → ids only, eventual consistency via events.

Naming#

  • Aggregate root usually = the noun (Order, Account, Booking).
  • Value objects = self-describing types (Money, Email, DateRange).
  • Domain events = past tense (OrderPlaced, not PlaceOrder).

Eventual consistency#

  • "When customer is renamed, update all their orders" - emit CustomerRenamed, listener updates each order aggregate eventually.
  • Surface staleness window to the product team; SLAs in seconds, not microseconds.

Refs#

  • Eric Evans: Domain-Driven Design (the original, 2003).
  • Vaughn Vernon: Implementing Domain-Driven Design (2013).
  • Vaughn Vernon: Domain-Driven Design Distilled (short version).
  • DDD Crew: dddcrew.com (modern resources + bounded-context canvas).

FAQ#

What is the difference between an entity and a value object in DDD?#

An entity has a unique identity that persists over time, while a value object is defined by its attributes and is immutable. Money and Address are value objects; Order and User are entities.

What is an aggregate root in domain-driven design?#

An aggregate root is the single entity that owns a cluster of related objects and enforces invariants for that cluster. All external access to the aggregate goes through the root.

When should I emit a domain event?#

Emit a domain event whenever a state change matters to other parts of the system, like OrderPlaced or PaymentCaptured. Events are past-tense facts the aggregate publishes after committing.

Are DDD tactical patterns only for microservices?#

No. Tactical patterns shape how you model one bounded context regardless of deployment. They work equally well in a modular monolith or a single service.

How are aggregates persisted to a database?#

A repository loads and saves whole aggregates as a unit, often inside a transaction. Each aggregate maps to one or several tables, and you never modify a child entity outside its root.

Further reading#

Curated, high-credibility sources for going deeper on this topic.