In This Guide
- 1. Why DDD? The Complexity Problem
- 2. Ubiquitous Language — The Foundation
- 3. Bounded Contexts — Drawing the Lines
- 4. Aggregates — Consistency Boundaries
- 5. Entities vs Value Objects
- 6. Domain Events — Making Things Happen
- 7. Repositories and Domain Services
- 8. Strategic Design — Context Mapping
- 9. When NOT to Use DDD
- 10. Frequently Asked Questions
Domain-Driven Design is a way of structuring code around business concepts instead of technical layers. Instead of organizing by Controllers, Services, Repositories, you organize by Order, Inventory, Shipping — the things your business actually cares about. We adopted DDD for a logistics client whose codebase had become a tangle of 200+ services with unclear boundaries. Six months later, their deploy frequency went from weekly (with fear) to daily (with confidence). The secret wasn't the tactical patterns — it was getting developers and domain experts speaking the same language.
1. Why DDD? The Complexity Problem
Most software projects don't fail because of technology. They fail because developers build the wrong thing, or build the right thing with the wrong structure. A payment system where "Order" means one thing to the sales team and another thing to the warehouse team will have bugs baked into its architecture.
DDD solves this by making the domain model — the mental model of how your business works — the central artifact of your software. Not the database schema. Not the API contract. The domain.
| Traditional Approach | DDD Approach |
|---|---|
| Start with database tables | Start with domain concepts and behaviors |
| One big model for the whole app | Multiple models, each optimized for its context |
| Organize by technical layer (controllers, services) | Organize by business capability (orders, shipping) |
| Business logic scattered across services | Business logic concentrated in domain objects |
| Devs and business speak different languages | Shared ubiquitous language across code and conversation |
DDD has two layers: strategic design (how you divide the system into bounded contexts) and tactical design (how you structure code within each context). Most teams jump to tactical patterns and skip strategic design. That's backwards — getting the boundaries right matters more than getting the aggregate pattern perfect.
2. Ubiquitous Language — The Foundation
Ubiquitous language means everyone — developers, product managers, domain experts — uses the same terms to describe the same concepts. And those terms appear in the code. If the business calls it a "shipment," the code has a Shipment class, not a DeliveryPackage or TransportUnit.
This sounds trivial until you discover that:
- The sales team calls it an "account," the billing team calls it a "customer," and the code calls it a
User— and they're three different things - A "cancellation" means something different in the subscription context (stop recurring billing) vs the order context (refund and return items)
- The word "product" in the catalog means SKU + description + images, but in inventory it means SKU + warehouse location + quantity
OrderPlaced as a domain event. No translation layer.
3. Bounded Contexts — Drawing the Lines
A bounded context is a boundary within which a particular model is defined and consistent. "Product" means one thing inside the Catalog context and another inside the Pricing context. Both are valid — they're just different perspectives on the same real-world thing.
E-commerce system — Bounded Contexts:
┌─────────────────────┐ ┌─────────────────────┐
│ Catalog Context │ │ Pricing Context │
│ │ │ │
│ Product: │ │ Product: │
│ - name │ │ - sku │
│ - description │ │ - basePrice │
│ - images[] │ │ - discountRules[] │
│ - categories[] │ │ - taxCategory │
│ - specifications │ │ - priceHistory[] │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
│ ProductId │ SKU
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Inventory Context │ │ Order Context │
│ │ │ │
│ StockItem: │ │ OrderItem: │
│ - sku │ │ - productId │
│ - warehouseId │ │ - quantity │
│ - quantity │ │ - unitPrice │
│ - reservations[] │ │ - lineTotal │
│ - reorderPoint │ │ - status │
└─────────────────────┘ └─────────────────────┘
Notice: "Product" appears in three contexts with completely different shapes. In the Catalog context, it has images and descriptions. In Pricing, it has discount rules and tax categories. In Inventory, it's not even called "Product" — it's a "StockItem." Each context has the exact data it needs, nothing more.
How to find your bounded contexts:
- Look at your organization chart — context boundaries often align with team boundaries
- Listen for the same word meaning different things to different people
- Watch for the places where your "universal" data model becomes awkward or bloated
- Ask: "If this team had its own database, what would the schema look like?"
4. Aggregates — Consistency Boundaries
An aggregate is a cluster of domain objects that are treated as a single unit for data changes. The aggregate root is the entry point — all modifications go through it. You never reach inside an aggregate to modify a child entity directly.
Think of an Order aggregate: it contains OrderItems, a ShippingAddress, and PaymentDetails. You don't modify an OrderItem directly — you call order.addItem() or order.removeItem(). The Order aggregate enforces business rules: "can't add items to a shipped order," "minimum order total is $10."
// Order Aggregate (TypeScript)
class Order {
private id: OrderId;
private items: OrderItem[] = [];
private status: OrderStatus = 'draft';
private events: DomainEvent[] = [];
// Business rule: can't add items to confirmed orders
addItem(product: ProductRef, quantity: number, unitPrice: Money): void {
if (this.status !== 'draft') {
throw new OrderAlreadyConfirmedError(this.id);
}
if (quantity < 1) {
throw new InvalidQuantityError(quantity);
}
const existing = this.items.find(i => i.productId.equals(product.id));
if (existing) {
existing.increaseQuantity(quantity);
} else {
this.items.push(new OrderItem(product.id, quantity, unitPrice));
}
this.events.push(new ItemAddedToOrder(this.id, product.id, quantity));
}
// Business rule: minimum order total + all items must have prices
confirm(): void {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
const total = this.calculateTotal();
if (total.isLessThan(Money.of(10, 'USD'))) {
throw new MinimumOrderNotMetError(total);
}
this.status = 'confirmed';
this.events.push(new OrderConfirmed(this.id, total));
}
private calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.lineTotal()),
Money.zero('USD')
);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this.events];
this.events = [];
return events;
}
}
Aggregate Design Rules
| Rule | Why | Example |
|---|---|---|
| Keep aggregates small | Large aggregates cause lock contention | Order has items, but Customer is a separate aggregate |
| Reference other aggregates by ID | Prevents loading the entire object graph | customerId: CustomerId not customer: Customer |
| One transaction per aggregate | Guarantees consistency within the boundary | Don't update Order and Inventory in the same transaction |
| Use domain events for cross-aggregate | Maintains decoupling between aggregates | OrderConfirmed triggers inventory reservation |
customerId.
5. Entities vs Value Objects
These are the building blocks inside aggregates. The distinction is simple but important:
| Entity | Value Object | |
|---|---|---|
| Identity | Has a unique ID that persists | Defined by its attributes, no ID |
| Equality | Equal if same ID (even if attributes differ) | Equal if all attributes are the same |
| Mutability | Can change over time | Immutable — create a new one instead |
| Examples | Customer, Order, Product | Money, Address, EmailAddress, DateRange |
// Value Object: Money (immutable, equality by value)
class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) throw new NegativeAmountError(amount);
if (!['USD', 'EUR', 'INR', 'GBP'].includes(currency)) {
throw new InvalidCurrencyError(currency);
}
}
static of(amount: number, currency: string): Money {
return new Money(Math.round(amount * 100) / 100, currency);
}
static zero(currency: string): Money {
return new Money(0, currency);
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new CurrencyMismatchError(this.currency, other.currency);
}
return Money.of(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency);
}
isLessThan(other: Money): boolean {
return this.amount < other.amount;
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
Value objects are one of DDD's most underused patterns. We see codebases where money is a raw number, emails are string, and coordinates are {lat: number, lng: number}. Wrapping these in value objects catches bugs at construction time (negative money, invalid email format) instead of at runtime in production.
6. Domain Events — Making Things Happen
Domain events represent something that happened in the domain that other parts of the system might care about. "OrderConfirmed," "PaymentReceived," "InventoryDepleted." They're the glue between aggregates and between bounded contexts.
// Domain events are facts — past tense, immutable
interface DomainEvent {
readonly eventId: string;
readonly occurredAt: Date;
readonly aggregateId: string;
}
class OrderConfirmed implements DomainEvent {
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
constructor(
readonly aggregateId: string,
readonly orderTotal: Money,
readonly items: ReadonlyArray<{ productId: string; quantity: number }>
) {}
}
class PaymentReceived implements DomainEvent {
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
constructor(
readonly aggregateId: string,
readonly amount: Money,
readonly paymentMethod: string
) {}
}
// Handlers react to events — in-process or via message broker
class WhenOrderConfirmed {
async handle(event: OrderConfirmed): Promise<void> {
// Reserve inventory
await this.inventoryService.reserve(event.items);
// Send confirmation email
await this.emailService.sendOrderConfirmation(event.aggregateId);
// Update analytics
await this.analytics.trackOrder(event.orderTotal);
}
}
Notice the naming: always past tense ("OrderConfirmed," not "ConfirmOrder"). Events are facts about what already happened. Commands are requests that might be rejected. This naming convention prevents confusion about whether something has happened or is being asked for.
7. Repositories and Domain Services
Repositories — Persistence Abstraction
A repository gives you the illusion that aggregates live in an in-memory collection. You save an Order, you find an Order by ID — the repository hides whether that's PostgreSQL, MongoDB, or a file. One repository per aggregate root.
// Repository interface (domain layer — no database specifics)
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
nextId(): OrderId;
}
// Implementation (infrastructure layer — PostgreSQL)
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.query(
'SELECT * FROM orders WHERE id = $1', [id.value]
);
if (!row) return null;
return this.toDomain(row); // hydrate the aggregate
}
async save(order: Order): Promise<void> {
const data = this.toPersistence(order);
await this.db.query(
`INSERT INTO orders (id, customer_id, status, total)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET status = $3, total = $4`,
[data.id, data.customerId, data.status, data.total]
);
// Publish domain events after successful persistence
const events = order.pullDomainEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
}
}
Domain Services — Logic That Doesn't Fit in Entities
Sometimes business logic doesn't naturally belong to any single entity. Calculating shipping cost depends on the order weight, the destination address, and the carrier rates. None of those entities "own" that logic. That's a domain service.
// Domain service — stateless, orchestrates multiple aggregates
class ShippingCostCalculator {
calculate(order: Order, destination: Address, carrier: Carrier): Money {
const weight = order.totalWeight();
const zone = carrier.getZone(destination);
const baseRate = carrier.rateForZone(zone, weight);
// Business rule: free shipping over $100 for domestic
if (zone.isDomestic() && order.subtotal().isGreaterThan(Money.of(100, 'USD'))) {
return Money.zero('USD');
}
return baseRate;
}
}
8. Strategic Design — Context Mapping
Context mapping describes how bounded contexts relate to each other. This is where most of the political and architectural decisions happen.
| Pattern | Relationship | Real-World Example |
|---|---|---|
| Shared Kernel | Two teams share a small model (both must agree on changes) | Orders and Billing share the Money value object |
| Customer-Supplier | Upstream provides, downstream consumes | Order context (upstream) feeds Shipping context (downstream) |
| Conformist | Downstream accepts upstream's model as-is | Your app conforms to Stripe's payment model — you don't negotiate |
| Anti-Corruption Layer | Translation layer protects your model from external models | Legacy ERP uses different terms — ACL translates to your domain language |
| Open Host Service | Published API with documented protocol | Your public API that multiple external consumers integrate with |
| Separate Ways | No integration — contexts are independent | Marketing site and core product have no shared model |
The Anti-Corruption Layer is the pattern we use most. When integrating with legacy systems or third-party APIs, we build a translation layer that converts their model to our domain language. This means our domain model stays clean even if the external system is messy. When Stripe changes their API, only the ACL changes — our payment domain stays the same.
// Anti-Corruption Layer for Stripe integration
class StripePaymentAdapter implements PaymentGateway {
async charge(payment: Payment): Promise<PaymentResult> {
// Translate OUR domain model → Stripe's model
const stripeCharge = await stripe.charges.create({
amount: payment.amount.toCents(), // Money → integer cents
currency: payment.amount.currency.toLowerCase(),
source: payment.tokenId,
metadata: { orderId: payment.orderId.value }
});
// Translate Stripe's response → OUR domain model
return new PaymentResult(
stripeCharge.status === 'succeeded' ? 'completed' : 'failed',
PaymentId.from(stripeCharge.id),
Money.of(stripeCharge.amount / 100, stripeCharge.currency.toUpperCase())
);
}
}
9. When NOT to Use DDD
DDD adds overhead. It's justified for complex business domains where getting the model wrong costs real money. It's overkill for simple CRUD applications.
- Simple CRUD apps — A blog, a todo list, a settings page. There's no complex domain logic to model. Active Record or a simple service layer is faster to build and maintain.
- Data-centric applications — A reporting dashboard that reads data and displays charts. The domain is thin — most logic is in queries, not behavior. Use a query-optimized approach.
- Small team, small scope — If two developers build an MVP in three months, the overhead of aggregates, repositories, and domain events will slow you down more than it helps. Ship first, refactor toward DDD when complexity demands it.
- No access to domain experts — DDD requires close collaboration with people who understand the business. If you're building in isolation from the business, you'll model the wrong thing anyway.
Project Structure We Use
src/
├── order/ ← Bounded Context
│ ├── domain/
│ │ ├── Order.ts ← Aggregate Root
│ │ ├── OrderItem.ts ← Entity
│ │ ├── Money.ts ← Value Object
│ │ ├── OrderStatus.ts ← Value Object (enum)
│ │ ├── OrderConfirmed.ts ← Domain Event
│ │ └── OrderRepository.ts ← Repository Interface
│ ├── application/
│ │ ├── PlaceOrder.ts ← Use Case / Command Handler
│ │ ├── GetOrderDetails.ts ← Query Handler
│ │ └── OrderService.ts ← Application Service
│ └── infrastructure/
│ ├── PostgresOrderRepo.ts← Repository Implementation
│ └── OrderController.ts ← HTTP Adapter
├── inventory/ ← Another Bounded Context
│ ├── domain/
│ ├── application/
│ └── infrastructure/
└── shared/ ← Shared Kernel
├── Money.ts
└── DomainEvent.ts
10. Frequently Asked Questions
Can I use DDD with a relational database, or does it require NoSQL?
DDD works perfectly with relational databases. PostgreSQL is our default choice. The repository pattern hides persistence details, so your domain model doesn't care whether data is in PostgreSQL, MongoDB, or DynamoDB. The mapping layer (repository implementation) handles the translation between domain objects and database rows. ORMs like Prisma or TypeORM can help but aren't required.
How do bounded contexts map to microservices?
A bounded context is a logical boundary; a microservice is a deployment boundary. They often align, but they don't have to. You can have multiple bounded contexts inside a monolith (modular monolith) or a single bounded context split across multiple microservices. We recommend starting with a modular monolith where each module is a bounded context, then extracting to microservices only when you need independent deployment or scaling.
What's the difference between a domain event and an integration event?
Domain events are internal to a bounded context — they represent what happened within that context's model. Integration events cross context boundaries and are part of your public contract. Domain events can change freely (internal refactoring). Integration events need versioning and backward compatibility because other teams depend on them. We publish domain events on an in-process bus and integration events to Kafka.
How do I introduce DDD to an existing codebase?
Don't rewrite. Start by identifying your most complex domain area — the part of the code that's hardest to change and causes the most bugs. Apply DDD tactically there: extract a domain model, introduce value objects for primitive obsession, define aggregate boundaries. Keep the rest of the codebase as-is. The anti-corruption layer pattern lets your new DDD module coexist with the legacy code. Migrate incrementally.
We've applied Domain-Driven Design to complex business domains in logistics, fintech, and healthcare — systems where getting the model wrong means lost revenue and compliance failures. Our approach is pragmatic: use DDD where it earns its complexity, and simpler patterns everywhere else.