DDD Tactical Patterns
Aggregates, entities, value objects, domain events, repositories — the implementation-side patterns that turn a bounded context into code
DDD Tactical Patterns
The strategic side of DDD (DDD Strategic Design) tells you where the boundaries go. The tactical patterns tell you how to write code inside one. Where strategic design is about lines on a context map, tactical design is about the class hierarchy that lives inside one of those lines.
Tactical patterns get more attention than they deserve, because they are concrete and show up in code reviews. Many teams adopt the tactical patterns without doing the strategic work — building "DDD-style code" inside a bounded context they have not actually identified. The result tends to be ceremonial code that looks like DDD but does not deliver its benefits. This page is the tactical side, but read DDD Strategic Design first; without it, the patterns below are mostly window-dressing.
The Core Building Blocks
DDD's tactical vocabulary, in dependency order:
- Value Object — an immutable thing identified by its value.
- Entity — a thing identified by an ID, with a lifecycle.
- Aggregate — a cluster of entities and value objects treated as a single transactional unit.
- Domain Event — a record of something that happened in the domain.
- Repository — the abstraction over persistence for an aggregate.
- Domain Service — operations that do not belong to any single entity.
- Factory — encapsulated construction of complex aggregates.
These are tools, not a checklist. Most domains use a subset.
Value Object
A value object is an object that does not have an identity of its own; it is its value. Two value objects with the same fields are interchangeable.
// Value object
class Money {
constructor(
readonly amount: number,
readonly currency: string
) {}
add(other: Money): Money {
assert(other.currency === this.currency)
return new Money(this.amount + other.amount, this.currency)
}
}
// $100 USD here and $100 USD there are "the same thing"
new Money(100, "USD") === new Money(100, "USD") // semantically equalProperties of value objects:
- Immutable. Operations return new instances, never mutate.
- Identity-free. No
idfield, no database row identity. Equal if all fields equal. - Side-effect-free. Methods are pure functions of the value.
- Lightweight. Cheap to create and copy.
Examples of natural value objects: Money, Address, EmailAddress, DateRange, Coordinates, Color, Distance, Percentage.
The mistake most domains make: modeling everything as an entity with an ID. A Money row in the database with an id column is a sign the modeling is wrong. Money has no identity; only the transaction using it does.
Entity
An entity is a thing with an identity that persists across changes. Two entities are "the same" if they share an ID, even if every other field differs.
class Customer {
constructor(
readonly id: CustomerId,
public name: string,
public email: EmailAddress,
public address: Address // a value object
) {}
changeEmail(newEmail: EmailAddress): void {
this.email = newEmail
}
}
// Two Customer objects with the same id are "the same Customer"
// even if their other fields have diverged.Properties of entities:
- Have an identity. A primary key, UUID, natural identifier.
- Have a lifecycle. Created, modified, possibly destroyed.
- Compare by identity. Two
Customer(id=42)references are the same customer. - Mutable. State changes over time.
Entities are typically what business people are referring to when they say "the thing" — the order, the customer, the invoice. Not every domain concept is an entity; only those whose identity matters across changes.
Aggregate
An aggregate is a cluster of related entities and value objects, treated as a single unit for transactional purposes. One entity in the aggregate is the aggregate root — the entry point through which all access happens.
Order (aggregate root, entity)
├── OrderLine (entity, only accessed through Order)
│ └── Money (value object)
├── ShippingAddress (value object)
└── BillingAddress (value object)Aggregate rules:
- One transaction, one aggregate. A single database transaction modifies exactly one aggregate. Cross-aggregate consistency is eventual (via domain events or sagas).
- All access goes through the root. External code touches
OrderLineonly viaorder.lines; never by holding a direct reference to a line. - Invariants protected by the root. Business rules like "total must equal sum of line items" are enforced inside
Order, not by the caller. - Small. Large aggregates produce lock contention and bloated transactions. Prefer many small aggregates over few large ones.
The hardest tactical question in DDD is what to make an aggregate root and what to put inside it. The right answer is driven by transactional consistency boundaries: what must change atomically together? Things outside that boundary go into separate aggregates.
A common mistake is making aggregates too large because they "feel related." Customer containing all their Orders containing all their OrderLines sounds natural until the first time you load a customer with thousands of orders into memory to update a phone number.
Domain Event
A domain event is a record of something that happened in the domain. Past tense: OrderPlaced, PaymentReceived, EmailChanged.
class Order {
// ...
ship(): void {
if (this.status !== "paid") {
throw new Error("Cannot ship unpaid order")
}
this.status = "shipped"
this.events.push(new OrderShipped(this.id, new Date()))
}
}Domain events are different from events in Event-Driven Architecture — they are an internal DDD concept first. The same event may also be published externally (via Outbox Pattern) but its primary role is:
- Recording what the domain did. The aggregate produces events as a side-effect of its operations.
- Communicating between aggregates. A change in aggregate A produces an event that triggers a change in aggregate B (eventually consistent).
- Driving projections. CQRS read models update from domain events.
- Audit log. The event sequence is, by construction, a record of what happened.
The discipline: every state change that other parts of the system care about should also be a domain event. Without this, cross-aggregate coordination becomes ad-hoc.
Repository
A repository is the abstraction over persistence for an aggregate. It looks like an in-memory collection from the outside, regardless of what storage technology is underneath.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>
save(order: Order): Promise<void>
findByCustomer(customerId: CustomerId): Promise<Order[]>
}Properties:
- One repository per aggregate root. Repositories return aggregates, not individual entities or value objects.
- Interface in the domain layer, implementation in infrastructure. The domain code knows nothing about SQL, MongoDB, or HTTP — only the repository interface.
- Returns fully-constructed aggregates. The repository hydrates the aggregate, including all its internal entities and value objects, before returning it.
- Transactional boundaries match aggregate boundaries.
save(order)is one transaction; the order's invariants are atomic.
Repositories are not DAOs. A DAO maps table to object. A repository maps aggregate to identity, regardless of how the aggregate is stored. An Order aggregate might span four tables in the database; the repository hides that.
Domain Service
A domain service is a domain operation that does not belong to any one entity or value object. Used when an operation involves multiple aggregates or when the natural home for a behavior is ambiguous.
class TransferService {
async transfer(
fromAccountId: AccountId,
toAccountId: AccountId,
amount: Money
): Promise<void> {
// Operates on two aggregates; not naturally part of either Account
// ...
}
}Use sparingly. Domain logic should live in entities and aggregates when possible. Domain services are the home for genuinely cross-aggregate logic, not a dumping ground for code that did not fit elsewhere.
Factory
A factory encapsulates the construction of complex aggregates. Useful when:
- Construction requires multiple steps or dependencies.
- Construction must enforce invariants that go beyond a constructor's reach.
- The "how to create one" logic is itself worth naming.
For simple aggregates, a constructor is fine. Factories are tools for genuinely complex creation, not a default.
Putting It Together
A small, complete example: a banking domain with Account as an aggregate.
// Value objects
class Money { /* ... */ }
class AccountId { /* ... */ }
// Aggregate root (entity)
class Account {
private events: DomainEvent[] = []
constructor(
readonly id: AccountId,
private balance: Money,
private status: "active" | "frozen" | "closed"
) {}
// All state changes go through methods that enforce invariants
deposit(amount: Money): void {
if (this.status !== "active") throw new Error("Account not active")
this.balance = this.balance.add(amount)
this.events.push(new MoneyDeposited(this.id, amount))
}
withdraw(amount: Money): void {
if (this.status !== "active") throw new Error("Account not active")
if (this.balance.lessThan(amount)) throw new Error("Insufficient funds")
this.balance = this.balance.subtract(amount)
this.events.push(new MoneyWithdrawn(this.id, amount))
}
// Read methods are simple accessors
currentBalance(): Money { return this.balance }
pendingEvents(): DomainEvent[] { return this.events }
}
// Repository (interface in domain)
interface AccountRepository {
findById(id: AccountId): Promise<Account | null>
save(account: Account): Promise<void>
}
// Domain service (cross-aggregate)
class TransferService {
constructor(private repo: AccountRepository, private bus: EventBus) {}
async transfer(fromId: AccountId, toId: AccountId, amount: Money): Promise<void> {
const from = await this.repo.findById(fromId)
const to = await this.repo.findById(toId)
if (!from || !to) throw new Error("Account not found")
from.withdraw(amount)
to.deposit(amount)
// Two transactions, two saves — cross-aggregate consistency is eventual
await this.repo.save(from)
await this.repo.save(to)
// Publish events for other contexts
for (const event of from.pendingEvents()) await this.bus.publish(event)
for (const event of to.pendingEvents()) await this.bus.publish(event)
}
}Notice what this example does and does not do:
- Invariants live inside
Account. The repository cannot save an invalid account because there is no way to construct one. - Cross-aggregate operations are explicit and not atomic. If the second save fails, you have a mismatch — handle with saga or accept the inconsistency.
- No SQL, no ORM in sight. The domain doesn't know.
Common Mistakes
- Skipping strategic design. The tactical patterns inside an undefined bounded context do nothing. Start with DDD Strategic Design.
- Everything is an entity. Many "entities" are actually value objects. Identity-less concepts should be modeled as such; default to value objects, only promote to entity when identity is genuinely needed.
- Large aggregates. "Customer with all orders with all line items" is one aggregate. Now updating an address requires loading 10,000 line items. Aggregate boundaries should follow consistency boundaries, not "things that feel related."
- Anemic domain model. Entities with only fields and getters/setters, all logic in services. This is the classic DDD anti-pattern — the patterns are in place but the behavior lives outside the entities, defeating the purpose.
- Direct access to entities inside an aggregate. Reaching into
order.lines[3].price = ...bypasses the aggregate root's invariants. All mutations go through root methods. - Cross-aggregate transactions. Modifying two aggregates in one transaction breaks the "one transaction, one aggregate" rule. Use eventual consistency via domain events.
- Repository per entity, not per aggregate.
OrderLineRepositoryis a smell.OrderLineis part of theOrderaggregate; onlyOrderhas a repository. - DDD ceremony without DDD substance. Writing
OrderIdas a wrapper around a UUID, declaring everythingreadonly, sprinkling inDomainService— without doing the strategic work — is cargo cult DDD.
Relation to Other Pages
- DDD Strategic Design — read this first; the tactical patterns only work inside a real bounded context.
- Anticorruption Layer — relevant when one aggregate's model must integrate with a foreign one.
- Event-Driven Architecture — domain events are the natural building block of event-driven systems within a DDD design.
- CQRS — aggregates on the write side; projections on the read side.
- Event Sourcing — domain events as the source of truth.
- Saga Pattern — cross-aggregate consistency via compensation.
Further Reading
- Eric Evans, Domain-Driven Design (2003), Part II — the original tactical patterns.
- Vaughn Vernon, Implementing Domain-Driven Design (2013) — the most accessible modern treatment. Chapters 5-12 cover the tactical patterns in depth.
- Vaughn Vernon, Domain-Driven Design Distilled (2016) — short version of both strategic and tactical.
- Scott Wlaschin, Domain Modeling Made Functional — the same patterns in a functional language (F#); illuminating contrast with the OO presentations.
- Mathias Verraes, blog posts — practitioner writing on aggregate design.
Pre-commit Checklist
- For each "entity" in my domain, does it actually have an identity that persists across changes? Or is it a value object I've over-promoted?
- For each aggregate, can I name the consistency boundary — what must be atomic? Is the aggregate cluster as small as that boundary allows?
- Do all mutations to an aggregate go through methods on the root, with invariants checked there?
- Are repositories one-per-aggregate-root, returning fully-constructed aggregates?
- Are cross-aggregate operations explicitly eventual (events / sagas), not pretending to be transactional?
- Do my entities have behavior, or are they anemic data containers with logic in services?
- Have I done the strategic work first, or am I applying tactical patterns to an undefined context?