Domain-Driven Design
Domain-Driven Design (DDD) focuses the model on the business domain itself, using a ubiquitous language, bounded contexts and aggregates to keep the code honest to the business. It is a technique for tackling complexity in the problem space, not a folder convention.
What is this?
DDD is a set of thinking tools for modeling complex domains. It centers on a ubiquitous language shared by domain experts and developers, splits the system into bounded contexts that each have their own model, and encodes business rules in aggregates and value objects rather than in anemic services. The model is a working simplification of the domain, and code is the model's implementation.
Why do you need it?
- A shared, explicit ubiquitous language removes the gap where the business says one thing and the code does another.
- Bounded contexts give each team one consistent model, eliminating the nightmare of one entity meaning three things.
- Aggregates enforce business invariants transactionally where they live, instead of relying on scattered checks.
- Models that mirror the domain are far easier to explain, extend and onboard new engineers onto.
- Strategic design aligns the architecture with business boundaries, which is the foundation for sensible service decomposition.
Mental model
Two departments using different words for the same thing
Two bounded contexts with their own models
Sales calls it 'a deal', Finance calls it 'an order'. DDD says: fine — give each context its own model and a translation layer at the boundary, instead of forcing one universal 'DealOrder' that satisfies nobody.
How it works
- Collaborate with domain experts in modeling sessions (event storming) to discover events, entities and invariants.
- Define bounded contexts: the line where a concept like 'order' means different things to different parts of the business.
- Express the core model in code as aggregates, entities and value objects that enforce invariants.
- Translate between contexts at their boundaries with anti-corruption layers, rather than sharing one bloated model.
- Distill the model continuously: the deeper model emerges from the conversation, not from the database schema.
Bounded contexts map to teams and modules; each holds its own model and invariant.
Key commands
mkdir -p billing-context/src/domain billing-context/src/applicationGive the billing context its own module with domain and application layers.
npm testRun domain tests that assert business invariants directly.
npx tsx src/index.tsStart the context service; each bounded context is independently deployable.
curl -s localhost:3001/orders/42Query the billing context's own model via its API.
{"id":"42","status":"placed","total":"129.50"}npm run lintEnforce the boundary: modules must not import across contexts.
Examples
An aggregate root enforcing an invariant
// billing-context/Order.tsexport class Order { private lines: OrderLine[] = [] private status: "draft" | "placed" | "paid" = "draft" constructor(private readonly id: string) {} addLine(productId: string, quantity: number, unitPrice: Money) { if (this.status !== "draft") { throw new Error("cannot modify a placed order") } if (quantity <= 0) throw new Error("quantity must be positive") this.lines.push({ productId, quantity, unitPrice }) } total(): Money { return this.lines.reduce( (sum, line) => sum.plus(line.unitPrice.times(line.quantity)), Money.zero() ) } place() { if (this.lines.length === 0) throw new Error("empty order cannot be placed") this.status = "placed" }}Order is the aggregate root: every change flows through it, so the invariant 'a placed order cannot be modified' and 'an order needs at least one line' are enforced in one place. Money is a value object with no identity. The model reads like the business rules, not like a table design.
In production
- A logistics platform models shipments and carriers as aggregates, and billing as a separate context with its own rules.
- An insurance company runs claims, underwriting and payments as distinct bounded contexts owned by different teams.
- Event storming sessions in a bank uncovered a hidden 'reversal' invariant that a table-driven model had silently dropped.
- Service boundaries in microservices are chosen from bounded contexts, not from how tall the team was that day.
Best practices
- Keep the ubiquitous language in a glossary that is reviewed with domain experts, and use those exact words in code.
- Make aggregates the transaction boundary: load one aggregate per transaction, never span aggregates in a write.
- Prefer value objects (Money, Currency, Address) over loose strings and numbers.
- Encode invariants as throwing domain methods, not as annotations spread across the service layer.
- Start with event storming to find the model; let the database schema follow the model, never the reverse.
Common mistakes
- Anemic domain models: every entity is a getter/setter bag and all logic lives in services.
- One giant shared 'Order' entity used by every context, creating implicit coupling.
- Splitting aggregates at table boundaries instead of invariant boundaries, breaking invariants.
- Treating DDD as folder structure (a 'domain/' folder) while the actual model is a CRUD facade.
Common issues
order total disagrees with the sum of its linesOrder totals were stored as a column and updated independently of the aggregate.
Remove the cached total and compute it from the aggregate's lines, or recompute inside the root's methods.
A placed order was modified by another serviceA second context wrote directly to the same table instead of calling the aggregate's API.
Give each context its own data store and translate at the boundary with an anti-corruption layer.
Two teams interpret 'customer' differently and produce conflicting rulesNo explicit bounded context was defined for customer.
Split 'customer' into per-context models (sales lead, billing account, support contact) with explicit translations.
Debugging tips
- When a bug crosses contexts, check the anti-corruption layer — translation mismatches are the usual culprit.
- If an invariant can be violated, find which method mutates state without going through the aggregate root.
- Replay the event-storming notes: a rule the code got wrong usually appears there first.
- When a model feels like a leaky abstraction, the ubiquitous language may have drifted from the code.
Interview checkpoint
Hands-on lab
- Define a value object for money
- Build an Order aggregate root that enforces business invariants
- Write domain tests that assert the invariants
- Expose the aggregate behind a repository interface
Model an Order Aggregate with Invariants
45 min · advanced
Quick quiz
Which concept draws the line where one model ends and another begins?
In DDD, it is good practice for an aggregate to reference other aggregates by object reference so services can traverse freely.
An 'order' entity has no behavior — every rule lives in a service that reads its fields and decides. What is this anti-pattern called and what should change?
Cheat sheet
Ubiquitous languageOne shared vocabulary for code and businessBounded contextBoundary of one consistent modelAggregate rootSingle entry point for a cluster of objectsEntityHas identity; survives changesValue objectDefined by attributes; immutableAnti-corruption layerTranslation at context boundariesEvent stormingDiscover the model from business eventsInvariants in the rootThrow from domain methodsReferences by idOne aggregate per transactionGlossaryKeep the language explicit and shared