Architecture
Advanced 17 min read Architecture

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.

bounded contextubiquitous languageaggregatevalue objectdomain modelevent storming
Introduction

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.

Motivation

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.
Think about it

Mental model

Imagine

Two departments using different words for the same thing

Think of

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.

Mechanics

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.

Domain experts + developers
Ubiquitous language
Context 1: Sales
Context 2: Billing
Anti-corruption layer
Modules & services
Reference

Key commands

mkdir -p billing-context/src/domain billing-context/src/application

Give the billing context its own module with domain and application layers.

npm test

Run domain tests that assert business invariants directly.

npx tsx src/index.ts

Start the context service; each bounded context is independently deployable.

curl -s localhost:3001/orders/42

Query the billing context's own model via its API.

{"id":"42","status":"placed","total":"129.50"}
npm run lint

Enforce the boundary: modules must not import across contexts.

Hands-on

Examples

An aggregate root enforcing an invariant

An aggregate root enforcing an invariant
// billing-context/Order.ts
export 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.

Real world

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.
Guidelines

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.
Watch out

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.
Root cause analysis

Common issues

Error
order total disagrees with the sum of its lines
Root cause

Order totals were stored as a column and updated independently of the aggregate.

Fix

Remove the cached total and compute it from the aggregate's lines, or recompute inside the root's methods.

Error
A placed order was modified by another service
Root cause

A second context wrote directly to the same table instead of calling the aggregate's API.

Fix

Give each context its own data store and translate at the boundary with an anti-corruption layer.

Error
Two teams interpret 'customer' differently and produce conflicting rules
Root cause

No explicit bounded context was defined for customer.

Fix

Split 'customer' into per-context models (sales lead, billing account, support contact) with explicit translations.

When things break

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.
Interviews

Interview checkpoint

Practice

Hands-on lab

Model an Order Aggregate with Invariants
45 min · advanced
In progress
Objectives
  • 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

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which concept draws the line where one model ends and another begins?

2

In DDD, it is good practice for an aggregate to reference other aggregates by object reference so services can traverse freely.

3

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?

Quick reference

Cheat sheet

Core vocabulary
Ubiquitous languageOne shared vocabulary for code and business
Bounded contextBoundary of one consistent model
Aggregate rootSingle entry point for a cluster of objects
EntityHas identity; survives changes
Value objectDefined by attributes; immutable
Anti-corruption layerTranslation at context boundaries
Modeling moves
Event stormingDiscover the model from business events
Invariants in the rootThrow from domain methods
References by idOne aggregate per transaction
GlossaryKeep the language explicit and shared
Go deeper

Official documentation