Domain-Driven Design (DDD): A Complete Practical Guide

Domain-Driven Design (DDD): A Complete Practical Guide
Domain-Driven Design is a software development approach introduced by Eric Evans in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software. The core insight: the biggest challenge in complex software is not technical — it is understanding the problem domain well enough to model it accurately in code.
DDD provides a vocabulary for discussing models with domain experts, patterns for structuring complex code, and principles for managing the boundaries between different parts of a system. This guide covers the essential DDD building blocks with TypeScript examples.
The Strategic Design Problem DDD Solves
In a large software project without DDD, you often see something like this:
// The "God class" anti-pattern — one Product class trying to serve all contexts
class Product {
id: string;
name: string;
description: string;
price: number;
costPrice: number;
weight: number;
dimensions: { length: number; width: number; height: number };
stockQuantity: number;
warehouseLocation: string;
category: string;
tags: string[];
seoTitle: string;
seoDescription: string;
imageUrls: string[];
supplier: Supplier;
reviews: Review[];
// ... 40 more fields
}This class serves multiple teams (shipping, pricing, marketing, inventory) but satisfies none of them perfectly. Shipping doesn't care about SEO. Pricing doesn't need warehouse location. Marketing doesn't touch cost price.
DDD solves this by recognizing that "Product" means different things to different parts of the business, and building separate models for each context.
Ubiquitous Language
The foundation of DDD is a shared vocabulary — Ubiquitous Language — used by both domain experts and developers. Every concept in the business domain has exactly one name, used consistently in conversations, documentation, and code.
Without ubiquitous language:
- Business analyst: "We need to track Clients"
- Developer: Creates a
UserRecordtable - Sales team: Refers to "Leads"
- Support team: Refers to "Customers"
- Result: Three different names for the same concept, confusion in every meeting
With ubiquitous language:
// Everyone agrees: the term is "Member"
// The code reflects the business language exactly
class Member {
readonly memberId: MemberId;
readonly email: Email;
private subscriptionPlan: SubscriptionPlan;
private joinedAt: Date;
upgrade(newPlan: SubscriptionPlan): void {
// "upgrade" is the term used in business conversations
if (!newPlan.isHigherThan(this.subscriptionPlan)) {
throw new InvalidUpgradeError('Can only upgrade to a higher plan');
}
this.subscriptionPlan = newPlan;
}
isEligibleForEarlyAccess(): boolean {
// "early access eligibility" is a real business rule, named with business vocabulary
const monthsAsMember = differenceInMonths(new Date(), this.joinedAt);
return monthsAsMember >= 6 && this.subscriptionPlan.isPremium();
}
}When a business stakeholder reads member.isEligibleForEarlyAccess(), they immediately understand what it does because it uses their vocabulary.
Bounded Contexts
A Bounded Context is a boundary within which a particular model is defined and applicable. The same word can have different meanings in different bounded contexts.
At Amazon, "Product" means:
- In the Catalog Context: name, description, images, category, reviews
- In the Inventory Context: SKU, quantity on hand, reorder point, warehouse location
- In the Shipping Context: weight, dimensions, fragility, hazmat classification
- In the Pricing Context: list price, cost, discount rules, tax category
Each context has its own model:
// Catalog Context
namespace Catalog {
class Product {
constructor(
readonly id: ProductId,
readonly name: string,
readonly description: string,
readonly images: Image[],
readonly category: Category,
) {}
}
}
// Inventory Context
namespace Inventory {
class Product {
constructor(
readonly sku: SKU,
private quantityOnHand: number,
readonly reorderPoint: number,
readonly warehouseLocation: WarehouseLocation,
) {}
reserve(quantity: number): void {
if (this.quantityOnHand < quantity) {
throw new InsufficientStockError(this.sku, quantity, this.quantityOnHand);
}
this.quantityOnHand -= quantity;
}
}
}
// Shipping Context
namespace Shipping {
class Product {
constructor(
readonly sku: SKU,
readonly weight: Weight,
readonly dimensions: Dimensions,
readonly isFragile: boolean,
readonly isHazmat: boolean,
) {}
calculateShippingCost(destination: Address): Money {
// Shipping-specific business logic
}
}
}Each context's Product class is small, focused, and contains only the data and behavior relevant to that context. No bloated 50-field god class.
Context Mapping
Bounded contexts don't exist in isolation — they must communicate. A Context Map documents the relationships between contexts.
Common patterns:
Shared Kernel: Two contexts share a common subset of the domain model. Changes require coordination.
Customer/Supplier: The Catalog context (supplier) provides product data to the Search context (customer). The supplier controls the interface.
Anti-Corruption Layer: When integrating with a legacy system or external API, build a translation layer that converts the external model into your domain model:
// Anti-corruption layer for a legacy ERP system
class LegacyERPAdapter {
async getInventoryLevel(productId: ProductId): Promise<InventoryLevel> {
// Legacy ERP uses numeric SKUs and different field names
const erpData = await this.erpClient.getStockLevel(productId.toNumericSKU());
// Translate legacy model to our domain model
return new InventoryLevel(
productId,
erpData.QTY_ON_HAND,
erpData.QTY_RESERVED,
erpData.WAREHOUSE_CODE,
);
}
}Entities
An Entity is an object with a unique identity that persists over time. Two entities with the same attribute values are not the same entity — they are distinct because they have different identities.
class Order {
constructor(
readonly orderId: OrderId, // Identity — this is what makes it an Order
private customerId: CustomerId,
private items: OrderItem[],
private status: OrderStatus,
) {}
// Two orders with the same items placed by the same customer
// are still DIFFERENT orders because they have different orderId values
equals(other: Order): boolean {
return this.orderId.equals(other.orderId);
}
}Entities have lifecycle — they are created, modified over time, and eventually archived or deleted.
Value Objects
A Value Object is an object defined entirely by its attributes, with no identity of its own. Two value objects with the same attributes are equal and interchangeable.
class Money {
constructor(
readonly amount: number,
readonly currency: Currency,
) {
if (amount < 0) throw new Error('Money cannot be negative');
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchError();
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(Math.round(this.amount * factor * 100) / 100, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency.equals(other.currency);
}
}
// Usage — value objects are immutable, create new instances for changes
const price = new Money(19.99, Currency.USD);
const discountedPrice = price.multiply(0.8); // Creates a NEW Money objectValue objects should be immutable. Never modify a value object; create a new one instead. This makes them safe to share, easy to test, and eliminates defensive copying.
Other examples: Email, PhoneNumber, Address, Coordinates, DateRange, Temperature.
Aggregates and Aggregate Roots
An Aggregate is a cluster of entities and value objects that are treated as a single unit for data changes. The Aggregate Root is the entity that controls access to everything inside the aggregate.
The key rule: external code can only reference the Aggregate Root, never objects inside the aggregate directly.
// Order is the Aggregate Root
class Order {
readonly orderId: OrderId;
private customerId: CustomerId;
private items: OrderItem[] = []; // OrderItem is inside the aggregate
private status: OrderStatus = OrderStatus.Draft;
private readonly events: DomainEvent[] = [];
// External code calls methods on Order — never manipulates items directly
addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
if (this.status !== OrderStatus.Draft) {
throw new OrderNotEditableError(this.orderId);
}
const existingItem = this.items.find(i => i.productId.equals(productId));
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this.items.push(new OrderItem(productId, quantity, unitPrice));
}
}
removeItem(productId: ProductId): void {
if (this.status !== OrderStatus.Draft) {
throw new OrderNotEditableError(this.orderId);
}
this.items = this.items.filter(i => !i.productId.equals(productId));
}
confirm(): void {
if (this.items.length === 0) {
throw new EmptyOrderError(this.orderId);
}
this.status = OrderStatus.Confirmed;
this.events.push(new OrderConfirmed(this.orderId, this.customerId, this.total()));
}
total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
new Money(0, Currency.USD)
);
}
getUncommittedEvents(): DomainEvent[] {
return [...this.events];
}
}
// OrderItem is an entity but NOT an Aggregate Root
class OrderItem {
constructor(
readonly productId: ProductId,
private quantity: number,
readonly unitPrice: Money,
) {
if (quantity <= 0) throw new Error('Quantity must be positive');
}
increaseQuantity(additionalQuantity: number): void {
this.quantity += additionalQuantity;
}
subtotal(): Money {
return this.unitPrice.multiply(this.quantity);
}
}Why aggregate roots matter: Without this pattern, you might have:
// WRONG — bypasses the Order's business rules
order.items[2].quantity = -5; // Corrupt state, no validationWith aggregate roots, all state changes go through Order's methods, which enforce business rules.
Domain Events
A Domain Event captures something meaningful that happened in the domain. Events are immutable facts in the past tense.
// Domain events are immutable facts
class OrderConfirmed {
readonly occurredAt: Date;
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
readonly totalAmount: Money,
) {
this.occurredAt = new Date();
}
}
class PaymentReceived {
constructor(
readonly orderId: OrderId,
readonly amount: Money,
readonly paymentMethod: PaymentMethod,
readonly occurredAt: Date,
) {}
}Other contexts subscribe to domain events and react accordingly:
OrderConfirmed→ Inventory context decrements stockOrderConfirmed→ Notification context sends confirmation emailPaymentReceived→ Fulfillment context starts picking the order
This decouples contexts: the Order context doesn't know or care that inventory needs to be updated. It just publishes the event.
Repositories
A Repository provides the illusion of an in-memory collection of aggregate roots. It abstracts all persistence details.
// Repository interface — defined in the domain layer
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomerId(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
}
// Implementation — in the infrastructure layer
class PostgreSQLOrderRepository implements OrderRepository {
constructor(private readonly db: Pool) {}
async findById(id: OrderId): Promise<Order | null> {
const rows = await this.db.query(
'SELECT * FROM orders WHERE id = $1',
[id.value]
);
if (rows.rowCount === 0) return null;
return OrderMapper.toDomain(rows.rows[0]);
}
async save(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.db.query(`
INSERT INTO orders (id, customer_id, status, total, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET status = $3, total = $4
`, [data.id, data.customerId, data.status, data.total, data.createdAt]);
// Dispatch domain events
for (const event of order.getUncommittedEvents()) {
await this.eventBus.publish(event);
}
}
}When to Use DDD (and When Not To)
| Suitable for DDD | Not suitable for DDD |
|---|---|
| Complex business logic with many rules | Simple CRUD applications |
| 10+ developers working on the same system | Small teams (1-3 developers) |
| Long-lived systems (5+ years) | Short-term projects or prototypes |
| Systems where the domain is the differentiator | Systems where the domain is simple and well-understood |
| Teams with access to domain experts | Systems with no meaningful business rules |
Signs you need DDD:
- You keep building the same business logic in multiple places
- Bug reports describe business logic errors, not technical errors
- New developers take months to understand what the code does
- Adding a feature requires understanding 15 different database tables
Frequently Asked Questions
Q: Is DDD only for microservices?
No. DDD is architectural thinking — it applies equally to monoliths. A well-structured modular monolith using DDD bounded contexts is often a better starting point than premature microservices. Each bounded context becomes a module in the monolith; extraction to a separate service becomes straightforward if needed later.
Q: When is DDD too much?
If your application is essentially "display a form, save to a database, display the saved data" (CRUD), DDD adds unnecessary complexity. DDD pays off when you have complex business logic, invariants to enforce, and domain experts who can define the language. For a blog engine or a simple inventory tracker, DDD is overkill.
Q: How do I start applying DDD to an existing codebase?
Start with ubiquitous language — run a workshop with domain experts and document the vocabulary. Then identify bounded contexts in your existing codebase (even informally). Finally, start applying tactical patterns (value objects, aggregates) to the highest-complexity, most bug-prone parts of the system. Do not attempt a big-bang DDD rewrite.
Key Takeaway
Domain-Driven Design is fundamentally about aligning your code with the business domain it represents. Ubiquitous language eliminates the translation errors that cause bugs. Bounded contexts prevent the God class problem by giving each part of the system its own model. Aggregates enforce business invariants by controlling access through the root. Start with the strategic patterns (language, contexts) before the tactical ones (entities, aggregates), and apply DDD selectively to the complex parts of your system rather than everywhere.
Read next: Micro-Frontend Architecture: Breaking the UI →
Part of the Software Architecture Hub — engineering the model.
