Skip to content

DDD Tactical Patterns — Detailed#

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