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) andOrderLine(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.
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 |