Domain-Driven Design for System Design
What Is Domain-Driven Design?
Domain-Driven Design (DDD) is an approach to software development introduced by Eric Evans in his 2003 book. It centres on one powerful idea: the software's structure and language should be rooted in the business domain. Instead of starting with databases, APIs, or frameworks, DDD starts with understanding the business problem — and lets that understanding drive every technical decision.
In the context of system design interviews and real-world architecture, DDD provides:
- A principled way to decompose a monolith into microservices — bounded contexts become service boundaries.
- A shared vocabulary (ubiquitous language) that eliminates miscommunication between engineers, product managers, and domain experts.
- Clear rules for data ownership — each aggregate enforces its own invariants, and each bounded context owns its data.
- Patterns for inter-service communication — context mapping tells you exactly how services relate to each other.
Ubiquitous Language
The ubiquitous language is a shared vocabulary between developers and domain experts that is used in code, conversations, documentation, and UI. It is not a glossary stuck in a wiki — it is the living language of the bounded context, reflected in class names, method names, API endpoints, and database columns.
Why It Matters
Consider an e-commerce platform. The marketing team says "customer," the warehouse team says "recipient," and the engineering team says user. These are the same person — but the subtle differences cause bugs, miscommunication, and wrong assumptions about data ownership.
| Without Ubiquitous Language | With Ubiquitous Language |
|---|---|
user.purchaseHistory | customer.orderHistory |
processTransaction() | placeOrder() |
item.status = 3 | order.markAsShipped() |
updateRecord(userId, data) | customer.changeShippingAddress(newAddress) |
doPayment(amount) | payment.authorize(orderTotal) |
Rules for Ubiquitous Language
- Use domain terms in code. If the domain expert says "Order," the class is
Order, notPurchaseRecord. - No synonyms. Pick one term and use it everywhere. If it's "Shipment," don't also call it "Delivery" or "Dispatch."
- Language is scoped to a bounded context. "Product" in the Catalog context (has description, images, price) is different from "Product" in the Warehouse context (has weight, shelf location, barcode).
- Evolve the language as understanding deepens. If a domain expert corrects you — "that's not a refund, it's a credit note" — change the code.
// ❌ Generic, developer-centric naming
class TransactionProcessor {
processItem(userId: string, itemId: string, qty: number) {
const record = db.insert("purchases", { userId, itemId, qty });
sendNotification(userId, "purchase_complete");
}
}
// ✅ Ubiquitous language — reads like the domain
class OrderService {
placeOrder(customerId: CustomerId, cart: ShoppingCart): Order {
const order = Order.createFrom(cart, customerId);
order.validate(); // enforces business invariants
this.orderRepository.save(order);
this.eventBus.publish(new OrderPlaced(order));
return order;
}
}
Bounded Contexts
A bounded context is an explicit boundary within which a particular domain model is defined and applicable. Inside the boundary, every term has a precise, unambiguous meaning. Different bounded contexts can use the same word to mean different things.
E-Commerce Bounded Contexts
Let's decompose an e-commerce platform into bounded contexts:
| Bounded Context | Core Concepts | "Product" Means… |
|---|---|---|
| Catalog | Product, Category, Brand, ProductImage, Review | A browseable item with name, description, images, price, and reviews |
| Order | Order, OrderLine, ShippingAddress, OrderStatus | A line item with SKU, quantity, and price snapshot at time of purchase |
| Payment | Payment, PaymentMethod, Authorization, Refund, CreditNote | N/A — Payment doesn't care about products, only order totals |
| Shipping | Shipment, Carrier, TrackingNumber, DeliveryEstimate, Package | A physical item with weight, dimensions, and handling requirements |
| User / Identity | User, Account, Credentials, Profile, Role | N/A — User context knows about authentication, not products |
Product class leads to a tangled, unmaintainable god object.
How Bounded Contexts Own Their Data
Each bounded context has its own database (or at minimum, its own schema). There are no shared tables between contexts. This is the single most important rule for achieving independence.
// Catalog Context — owns product browsing data
catalog_db.products:
{ id: "prod-001", name: "Running Shoes", description: "...",
price: 129.99, images: [...], category: "Footwear", reviews: [...] }
// Order Context — owns order data, snapshots product info at purchase time
order_db.orders:
{ id: "ord-456", customerId: "cust-789", status: "CONFIRMED",
lines: [
{ productId: "prod-001", name: "Running Shoes", priceAtPurchase: 129.99, qty: 1 }
],
shippingAddress: { street: "123 Main St", city: "Portland", zip: "97201" } }
// Shipping Context — knows physical attributes, not prices
shipping_db.shipments:
{ id: "ship-101", orderId: "ord-456", carrier: "FedEx",
packages: [
{ weight: 1.2, dimensions: "30x20x15cm", items: ["prod-001"] }
],
tracking: "FX123456789", estimatedDelivery: "2026-04-20" }
Communication between contexts happens through well-defined interfaces: APIs, domain events, or shared messages. Never through shared databases.
Context Mapping & Strategic Patterns
A context map is a diagram showing all bounded contexts and the relationships between them. It answers: "How do these contexts talk to each other, and who has the power?" Context maps are crucial for system design because they determine your integration architecture.
Strategic Relationship Patterns
1. Shared Kernel
Two contexts share a small, explicitly defined subset of the domain model — typically value objects or a small library. Both teams must agree on changes.
// Shared Kernel: common value objects used by Order and Shipping
// Both teams co-own this code — changes require coordination
// shared-kernel/src/Money.ts
class Money {
constructor(readonly amount: number, readonly currency: Currency) {}
add(other: Money): Money {
if (this.currency !== other.currency) throw new CurrencyMismatch();
return new Money(this.amount + other.amount, this.currency);
}
}
// shared-kernel/src/Address.ts
class Address {
constructor(
readonly street: string, readonly city: string,
readonly state: string, readonly zip: string,
readonly country: Country
) {}
equals(other: Address): boolean {
return this.street === other.street && this.zip === other.zip
&& this.country === other.country;
}
}
Use when: Two closely aligned teams need a small common model. Risk: Coupling — one team's change can break the other. Keep it minimal.
2. Customer–Supplier (Upstream/Downstream)
One context (upstream/supplier) provides data or services that another context (downstream/customer) depends on. The upstream team prioritizes the downstream team's needs in their planning.
// Upstream: Catalog Context provides product data
// Downstream: Order Context consumes it
// Catalog (upstream) exposes an API
GET /api/catalog/products/{productId}
→ { id, name, price, sku, inStock, weight }
// Order (downstream) calls Catalog when placing an order
class OrderService {
async placeOrder(cart: ShoppingCart): Promise<Order> {
for (const item of cart.items) {
// Customer (Order) depends on Supplier (Catalog)
const product = await this.catalogClient.getProduct(item.productId);
if (!product.inStock) throw new ProductOutOfStock(item.productId);
order.addLine(product.id, product.name, product.price, item.qty);
}
return order;
}
}
Use when: You control both sides and the upstream team can accommodate downstream needs. This is the most common relationship in enterprise systems.
3. Conformist
The downstream context conforms to the upstream context's model — no translation, no negotiation. You use their API and their data structures as-is.
// Conformist: our Shipping context uses FedEx's API and model directly
// We have no power to change FedEx's data format
interface FedExShipmentRequest {
shipper: FedExAddress; // FedEx's address format, not ours
recipient: FedExAddress;
packageDetails: FedExPackage; // weight in lbs, dimensions in inches
serviceType: "FEDEX_GROUND" | "FEDEX_EXPRESS" | "FEDEX_2DAY";
}
// We conform to their model — mapping our domain objects to their format
class FedExShippingAdapter {
createShipment(shipment: Shipment): FedExShipmentRequest {
return {
shipper: this.toFedExAddress(shipment.origin),
recipient: this.toFedExAddress(shipment.destination),
packageDetails: {
weight: { units: "LB", value: shipment.weightKg * 2.205 },
dimensions: { units: "IN", ... }
},
serviceType: this.mapCarrierService(shipment.service)
};
}
}
Use when: Integrating with a third-party system you can't influence — payment gateways, shipping carriers, government APIs.
4. Anti-Corruption Layer (ACL)
A translation layer that protects your bounded context from another context's model. Instead of letting external concepts leak in, you translate at the boundary.
// Anti-Corruption Layer: protects our Order context from legacy inventory system
// The legacy system uses cryptic codes and a terrible data model
// Legacy Inventory API response (upstream, can't change)
{ "itm_cd": "SKU-9912", "avl_qty": 42, "whs_loc": "B3-R7-S12",
"sts": "A", "lst_upd": "20260415" }
// Our ACL translates this into our clean domain model
class InventoryAntiCorruptionLayer {
async checkAvailability(productId: ProductId): Promise<StockLevel> {
const legacy = await this.legacyInventoryClient.getItem(
this.mapToLegacySku(productId)
);
// Translate legacy gibberish into our ubiquitous language
return new StockLevel(
productId,
legacy.avl_qty,
this.mapLegacyStatus(legacy.sts), // "A" → Available
this.parseDate(legacy.lst_upd) // "20260415" → Date
);
}
private mapLegacyStatus(code: string): StockStatus {
const mapping: Record<string, StockStatus> = {
"A": StockStatus.AVAILABLE,
"D": StockStatus.DISCONTINUED,
"B": StockStatus.BACKORDERED,
"H": StockStatus.ON_HOLD,
};
return mapping[code] ?? StockStatus.UNKNOWN;
}
}
Use when: Integrating with legacy systems, third-party APIs with bad models, or any system whose model you don't want leaking into your domain. The ACL is one of the most important patterns in system design — it's how you keep your domain clean.
5. Open Host Service / Published Language
A context exposes a well-defined, versioned API (the "open host") using a shared language (often JSON/Protobuf schemas) that multiple consumers can use.
// Open Host Service: Catalog context publishes a stable, versioned API
// Multiple downstream contexts (Order, Search, Recommendation) consume it
// Published Language — versioned protobuf schema
syntax = "proto3";
package catalog.v2;
service CatalogService {
rpc GetProduct (GetProductRequest) returns (Product);
rpc SearchProducts (SearchRequest) returns (SearchResponse);
rpc GetProductsByCategory (CategoryRequest) returns (ProductList);
}
message Product {
string product_id = 1;
string name = 2;
Money price = 3;
string description = 4;
repeated string image_urls = 5;
bool in_stock = 6;
string category = 7;
}
// Any downstream context can consume this API
// without special accommodation from the Catalog team
Use when: You need to serve many downstream consumers with a stable, well-documented API. This is the standard approach for platform services and internal APIs.
Summary of Strategic Patterns
| Pattern | Power Dynamic | Coupling | Real-World Example |
|---|---|---|---|
| Shared Kernel | Equal partnership | High (shared code) | Two teams sharing Money/Address types |
| Customer–Supplier | Upstream serves downstream | Medium | Catalog → Order, User → all services |
| Conformist | Upstream dictates | High (no translation) | Using Stripe/FedEx API as-is |
| Anti-Corruption Layer | Downstream protects itself | Low (translated) | Wrapping a legacy inventory system |
| Open Host / Published Lang. | Upstream serves many | Low (stable contract) | Internal platform API (Catalog gRPC) |
▶ Context Map — E-Commerce Bounded Contexts
Step through each relationship type between our 5 bounded contexts to see how they integrate.
Tactical Patterns — Building Blocks
Strategic patterns tell you where to draw boundaries. Tactical patterns tell you how to model within those boundaries. These are the building blocks of your domain layer.
Entities
Objects defined by their identity, not their attributes. Two entities with the same attributes but different IDs are different objects. Entities have a lifecycle and are mutable.
class Order {
readonly id: OrderId; // ← identity
private status: OrderStatus;
private lines: OrderLine[];
private shippingAddress: Address;
// Two orders with different IDs are different, even if they
// contain the same items for the same customer
equals(other: Order): boolean {
return this.id.equals(other.id); // identity-based equality
}
// Entities encapsulate behavior — not just getters/setters
addLine(productId: ProductId, name: string, price: Money, qty: number): void {
if (this.status !== OrderStatus.DRAFT)
throw new OrderAlreadySubmitted(this.id);
this.lines.push(new OrderLine(productId, name, price, qty));
}
submit(): void {
if (this.lines.length === 0) throw new EmptyOrder(this.id);
this.status = OrderStatus.SUBMITTED;
}
}
Value Objects
Objects defined by their attributes, not identity. Two value objects with the same attributes are equal and interchangeable. Value objects are immutable.
class Money {
constructor(readonly amount: number, readonly currency: Currency) {
if (amount < 0) throw new NegativeAmount();
}
// Value-based equality — no ID needed
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) throw new CurrencyMismatch();
return new Money(this.amount + other.amount, this.currency); // returns NEW object
}
}
class Address {
constructor(
readonly street: string, readonly city: string,
readonly state: string, readonly zip: string,
readonly country: Country
) {}
// No setter — to "change" an address, create a new one
withCity(newCity: string): Address {
return new Address(this.street, newCity, this.state, this.zip, this.country);
}
}
class EmailAddress {
readonly value: string;
constructor(raw: string) {
if (!raw.match(/^[^@]+@[^@]+\.[^@]+$/)) throw new InvalidEmail(raw);
this.value = raw.toLowerCase();
}
}
- Does it have a unique identity that persists over time? → Entity
- Is it defined purely by its attributes? → Value Object
- Can two instances with the same data be used interchangeably? → Value Object
- Does it need to be tracked, updated, and referenced by ID? → Entity
Order = Entity (has OrderId), Money = Value Object ($10 USD is $10 USD), Address = Value Object (123 Main St is 123 Main St).
Aggregates & 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 only entry point — all external access goes through it.
Rules of Aggregates
- The root entity is the only member accessible from outside. External code cannot hold a reference to an internal entity.
- Invariants are enforced within the aggregate boundary. The aggregate root ensures that business rules are never violated.
- Transactions don't span aggregates. Each aggregate is a consistency boundary — save one aggregate per transaction.
- Reference other aggregates by ID, not by object reference. An Order holds
customerId: CustomerId, notcustomer: Customer. - Keep aggregates small. Large aggregates cause contention and performance problems.
// ┌─────────────── Order Aggregate ───────────────┐
// │ │
// │ Order (Aggregate Root) │
// │ ├── OrderLine (Entity, internal) │
// │ │ └── Money (Value Object) │
// │ ├── OrderLine (Entity, internal) │
// │ │ └── Money (Value Object) │
// │ ├── ShippingAddress (Value Object) │
// │ └── OrderStatus (Value Object / Enum) │
// │ │
// │ Invariants enforced by Order: │
// │ - Order total must be positive │
// │ - Max 50 line items per order │
// │ - Cannot modify after submission │
// │ - Shipping address required for submission │
// └────────────────────────────────────────────────┘
class Order { // ← Aggregate Root
readonly id: OrderId;
private status: OrderStatus = OrderStatus.DRAFT;
private lines: OrderLine[] = [];
private shippingAddress: Address | null = null;
readonly customerId: CustomerId; // reference by ID, not object
addLine(productId: ProductId, name: string, price: Money, qty: number): void {
this.assertDraft();
if (this.lines.length >= 50) throw new TooManyLines(this.id);
const existing = this.lines.find(l => l.productId.equals(productId));
if (existing) { existing.increaseQuantity(qty); return; }
this.lines.push(new OrderLine(productId, name, price, qty));
}
removeLine(productId: ProductId): void {
this.assertDraft();
this.lines = this.lines.filter(l => !l.productId.equals(productId));
}
setShippingAddress(address: Address): void {
this.assertDraft();
this.shippingAddress = address;
}
submit(): OrderPlaced {
this.assertDraft();
if (this.lines.length === 0) throw new EmptyOrder(this.id);
if (!this.shippingAddress) throw new MissingShippingAddress(this.id);
this.status = OrderStatus.SUBMITTED;
return new OrderPlaced(this.id, this.total(), this.customerId);
}
total(): Money {
return this.lines.reduce(
(sum, line) => sum.add(line.subtotal()),
new Money(0, Currency.USD)
);
}
private assertDraft(): void {
if (this.status !== OrderStatus.DRAFT)
throw new OrderNotModifiable(this.id, this.status);
}
}
class OrderLine { // ← Internal entity (not accessible from outside aggregate)
constructor(
readonly productId: ProductId,
readonly productName: string,
private pricePerUnit: Money,
private quantity: number
) {}
increaseQuantity(qty: number): void { this.quantity += qty; }
subtotal(): Money {
return new Money(this.pricePerUnit.amount * this.quantity, this.pricePerUnit.currency);
}
}
▶ Order Aggregate Structure
Visualize the Order aggregate: root entity, internal entities, value objects, and the boundary rule.
Domain Events
A domain event is something that happened in the domain that domain experts care about. Events are named in past tense and capture what occurred, enabling loose coupling between bounded contexts.
// Domain events from the Order context
class OrderPlaced {
readonly occurredAt = new Date();
constructor(
readonly orderId: OrderId,
readonly orderTotal: Money,
readonly customerId: CustomerId,
readonly lineItems: ReadonlyArray<{ productId: string; qty: number; price: number }>
) {}
}
class OrderShipped {
readonly occurredAt = new Date();
constructor(
readonly orderId: OrderId,
readonly trackingNumber: string,
readonly carrier: string,
readonly estimatedDelivery: Date
) {}
}
class OrderCancelled {
readonly occurredAt = new Date();
constructor(
readonly orderId: OrderId,
readonly reason: CancellationReason,
readonly refundAmount: Money
) {}
}
// Other contexts subscribe to these events:
// Payment listens for OrderPlaced → initiates payment capture
// Shipping listens for OrderPlaced → reserves inventory, schedules shipment
// Notification listens for OrderShipped → sends tracking email to customer
// Analytics listens for all events → builds dashboards and reports
Repositories
A repository provides a collection-like interface for accessing aggregates. It abstracts away the persistence mechanism — the domain doesn't know if data is in PostgreSQL, MongoDB, or memory.
// Repository interface — defined in the domain layer
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
nextId(): OrderId;
}
// Infrastructure implementation — 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;
const lines = await this.db.query(
"SELECT * FROM order_lines WHERE order_id = $1", [id.value]
);
return this.toDomain(row, lines); // maps DB rows → domain objects
}
async save(order: Order): Promise<void> {
await this.db.transaction(async (tx) => {
await tx.query(
`INSERT INTO orders (id, customer_id, status, shipping_address, total)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (id) DO UPDATE SET status=$3, shipping_address=$4, total=$5`,
[order.id.value, order.customerId.value, order.status,
JSON.stringify(order.shippingAddress), order.total().amount]
);
// Save all order lines (delete + re-insert for simplicity)
await tx.query("DELETE FROM order_lines WHERE order_id = $1", [order.id.value]);
for (const line of order.getLines()) {
await tx.query(
`INSERT INTO order_lines (order_id, product_id, name, price, qty)
VALUES ($1,$2,$3,$4,$5)`,
[order.id.value, line.productId, line.productName,
line.pricePerUnit.amount, line.quantity]
);
}
});
}
}
// Note: One repository per aggregate root. No repository for OrderLine
// because OrderLine is only accessed through the Order aggregate.
Domain Services
When a business operation doesn't naturally belong to a single entity or value object, it goes in a domain service. Domain services are stateless and express operations in ubiquitous language.
// Domain Service: pricing logic that spans multiple concepts
class PricingService {
calculateOrderTotal(
lines: OrderLine[],
shippingAddress: Address,
coupon: Coupon | null
): OrderPricing {
let subtotal = lines.reduce(
(sum, l) => sum.add(l.subtotal()), Money.zero(Currency.USD)
);
const tax = this.taxCalculator.calculate(subtotal, shippingAddress);
const shipping = this.shippingCalculator.estimate(lines, shippingAddress);
let discount = Money.zero(Currency.USD);
if (coupon) {
discount = coupon.apply(subtotal); // percentage or fixed amount
}
const total = subtotal.add(tax).add(shipping).subtract(discount);
return new OrderPricing(subtotal, tax, shipping, discount, total);
}
}
// Domain Service: transferring inventory between warehouses
class InventoryTransferService {
transfer(
sourceWarehouse: Warehouse,
targetWarehouse: Warehouse,
productId: ProductId,
quantity: number
): InventoryTransferred {
sourceWarehouse.removeStock(productId, quantity);
targetWarehouse.addStock(productId, quantity);
return new InventoryTransferred(
sourceWarehouse.id, targetWarehouse.id, productId, quantity
);
}
}
Tactical Patterns Summary
| Pattern | What It Is | E-Commerce Example |
|---|---|---|
| Entity | Identity + lifecycle + behavior | Order, Customer, Product |
| Value Object | Immutable, attribute-based equality | Money, Address, Email, SKU |
| Aggregate | Consistency boundary, transactional unit | Order (root) + OrderLines |
| Domain Event | Something that happened in the domain | OrderPlaced, PaymentCaptured |
| Repository | Collection-like persistence abstraction | OrderRepository, ProductRepository |
| Domain Service | Stateless cross-entity operations | PricingService, InventoryTransferService |
DDD Maps to Microservices
The single most valuable insight from DDD for system design: bounded context = microservice boundary. This gives you a principled, non-arbitrary way to decompose a system.
| DDD Concept | Microservice Equivalent | Why |
|---|---|---|
| Bounded Context | Microservice | Independent deployable with its own data |
| Context Map | Service integration architecture | Shows how services communicate |
| Ubiquitous Language | API contract / Protobuf schema | Shared vocabulary in API surface |
| Domain Event | Async message (Kafka/RabbitMQ) | Loose coupling between services |
| Anti-Corruption Layer | API Gateway / Adapter service | Protects from external models |
| Aggregate | Service's internal consistency boundary | Transaction scope within a service |
| Shared Kernel | Shared library (npm/Maven package) | Common types versioned as a package |
E-Commerce Service Architecture (from DDD)
┌───────────────────────────────────────────────────────────────────────┐
│ API Gateway │
│ Routes requests to appropriate bounded context / microservice │
└────────┬──────────┬──────────┬──────────┬──────────┬─────────────────┘
│ │ │ │ │
┌────▼───┐ ┌───▼────┐ ┌──▼────┐ ┌──▼─────┐ ┌──▼──────┐
│Catalog │ │ Order │ │Payment│ │Shipping│ │Identity │
│Service │ │Service │ │Service│ │Service │ │Service │
│ │ │ │ │ │ │ │ │ │
│Products│ │Orders │ │Pay- │ │Ship- │ │Users │
│Categor.│ │Order │ │ments │ │ments │ │Accounts │
│Reviews │ │Lines │ │Refunds│ │Packages│ │Roles │
└───┬────┘ └───┬────┘ └──┬────┘ └───┬────┘ └────┬────┘
│ │ │ │ │
┌───▼───┐ ┌──▼───┐ ┌──▼──┐ ┌───▼──┐ ┌───▼───┐
│Catalog│ │Order │ │Pay- │ │Ship- │ │User │
│ DB │ │ DB │ │ DB │ │ DB │ │ DB │
└───────┘ └──────┘ └─────┘ └──────┘ └───────┘
──── Events flow via Kafka / message bus ────
OrderPlaced → Payment captures funds
PaymentCaptured → Shipping schedules pickup
OrderShipped → Notification sends tracking email
OrderCancelled → Payment issues refund
Event Storming for Domain Discovery
Event Storming is a collaborative workshop technique (by Alberto Brandolini) used to rapidly explore a domain and discover bounded contexts. It's one of the most effective ways to go from "we need microservices" to "here are the bounded contexts and their events."
How It Works
- Phase 1 — Chaotic Exploration: Everyone (engineers, domain experts, product managers) puts orange sticky notes on a timeline wall. Each note is a domain event in past tense: "Order Placed," "Payment Failed," "Shipment Dispatched."
- Phase 2 — Enforce Timeline: Arrange events in chronological order. Duplicates are merged. Gaps in the timeline reveal missing events.
- Phase 3 — Add Commands & Actors: For each event, add a blue command that triggers it ("Place Order" → "Order Placed") and a yellow actor who initiates the command ("Customer," "Admin," "System Timer").
- Phase 4 — Identify Aggregates: Group commands and events around the entities they affect. Add yellow aggregate notes ("Order," "Payment," "Shipment").
- Phase 5 — Draw Boundaries: Look for natural clusters. Where the language changes, where different teams own different concepts — those are bounded context boundaries.
E-Commerce Event Storming Walkthrough
Here's what an event storming session might produce for our e-commerce platform:
Timeline → (left to right, chronological)
═══ CATALOG CONTEXT ═══
Actor: Merchandiser
Command: "Add Product to Catalog"
Event: "Product Added"
Command: "Update Product Price"
Event: "Product Price Changed"
Command: "Discontinue Product"
Event: "Product Discontinued"
═══ ORDER CONTEXT ═══
Actor: Customer
Command: "Add Item to Cart" → Event: "Item Added to Cart"
Command: "Remove Item from Cart" → Event: "Item Removed from Cart"
Command: "Place Order" → Event: "Order Placed" ← PIVOTAL EVENT
Actor: System (timer)
Policy: "If unpaid for 30 min" → Event: "Order Expired"
Actor: Customer
Command: "Cancel Order" → Event: "Order Cancelled"
═══ PAYMENT CONTEXT ═══
Trigger: "Order Placed" event arrives
Command: "Authorize Payment" → Event: "Payment Authorized"
or Event: "Payment Failed"
Trigger: "Order Shipped" event
Command: "Capture Payment" → Event: "Payment Captured"
Trigger: "Order Cancelled" event
Command: "Refund Payment" → Event: "Refund Issued"
═══ SHIPPING CONTEXT ═══
Trigger: "Payment Authorized" event
Command: "Reserve Inventory" → Event: "Inventory Reserved"
or Event: "Inventory Unavailable"
Actor: Warehouse Staff
Command: "Pick and Pack Order" → Event: "Order Packed"
Command: "Hand Off to Carrier" → Event: "Shipment Dispatched"
Actor: Carrier (external system)
Event: "Shipment In Transit"
Event: "Shipment Delivered"
═══ USER / IDENTITY CONTEXT ═══
Actor: Visitor
Command: "Register Account" → Event: "Account Created"
Command: "Verify Email" → Event: "Email Verified"
Actor: Customer
Command: "Update Profile" → Event: "Profile Updated"
Command: "Change Password" → Event: "Password Changed"
- "Order Placed" is a pivotal event — it triggers flows in Payment, Shipping, and Notification contexts. This tells you it's the most important domain event and should be published on the event bus.
- The language changes at context boundaries. In Catalog, it's "Product." In Order, it's "OrderLine" (a snapshot). In Shipping, it's "Package." These language shifts are exactly where bounded context boundaries should be drawn.
- Policies reveal automation. "If unpaid for 30 min → expire order" is a policy (automation rule) that lives in the Order context.
- External systems are separate contexts. The Carrier (FedEx, UPS) is an external system — we use a Conformist or ACL pattern to integrate.
E-Commerce Domain Decomposition (Full Example)
Let's put everything together and decompose a realistic e-commerce system using DDD principles. This is the kind of answer that impresses in a system design interview.
Step 1: Identify Bounded Contexts
| Context | Responsibility | Aggregates | Key Events Published |
|---|---|---|---|
| Catalog | Browse & search products | Product, Category | ProductAdded, PriceChanged, ProductDiscontinued |
| Order | Cart → checkout → order lifecycle | Order, Cart | OrderPlaced, OrderCancelled, OrderExpired |
| Payment | Authorize, capture, refund | Payment, Refund | PaymentAuthorized, PaymentCaptured, RefundIssued |
| Shipping | Inventory, pick/pack, dispatch, track | Shipment, Inventory, Package | InventoryReserved, ShipmentDispatched, ShipmentDelivered |
| User / Identity | Auth, profiles, preferences | User, Account | AccountCreated, EmailVerified, ProfileUpdated |
Step 2: Define Context Relationships
Context Map:
┌────────────┐ Customer–Supplier ┌────────────┐
│ Catalog │ ─────────────────────▶ │ Order │
│ (upstream) │ Order reads product │(downstream)│
└────────────┘ data from Catalog API └─────┬──────┘
│
┌───────────────────────────────────────┤
│ Domain Event: "OrderPlaced" │
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ Payment │◀─── Domain Events ──────▶│ Shipping │
│ │ "PaymentCaptured" → │ │
└────────────┘ triggers shipping └────────────┘
┌────────────┐ Shared Kernel ┌────────────┐
│ Order │ ◀═══════════════════▶ │ Shipping │
└────────────┘ (Money, Address types) └────────────┘
┌────────────┐ Anti-Corruption Layer
│ Shipping │ ──── ACL ────▶ [FedEx / UPS External API]
└────────────┘ Translates external carrier model
┌────────────┐ Open Host Service ┌─── All ───┐
│ User / │ ═══════════════════▶ │ other │
│ Identity │ Stable auth/profile │ services │
└────────────┘ API consumed by all └───────────┘
Step 3: Design Aggregates per Context
// ─── Catalog Context ───
Aggregate: Product
Root: Product (id, name, description, price, images[], category, isActive)
Value Objects: Money, ProductImage, Category
Aggregate: Review
Root: Review (id, productId, customerId, rating, text, createdAt)
Value Objects: Rating (1-5), ReviewText
// ─── Order Context ───
Aggregate: Order
Root: Order (id, customerId, status, shippingAddress, lines[])
Entities: OrderLine (productId, name, priceAtPurchase, qty)
Value Objects: Money, Address, OrderStatus
Aggregate: Cart
Root: Cart (id, customerId, items[])
Entities: CartItem (productId, name, price, qty)
Value Objects: Money
// ─── Payment Context ───
Aggregate: Payment
Root: Payment (id, orderId, amount, method, status, authorizedAt, capturedAt)
Value Objects: Money, PaymentMethod, PaymentStatus
Aggregate: Refund
Root: Refund (id, paymentId, amount, reason, status, issuedAt)
Value Objects: Money, RefundReason
// ─── Shipping Context ───
Aggregate: Shipment
Root: Shipment (id, orderId, packages[], carrier, tracking, status)
Entities: Package (weight, dimensions, items[])
Value Objects: TrackingNumber, CarrierInfo, ShipmentStatus
Aggregate: InventoryItem
Root: InventoryItem (productId, warehouseId, quantityOnHand, reserved)
Value Objects: Quantity
// ─── User / Identity Context ───
Aggregate: User
Root: User (id, email, passwordHash, profile, roles[], isVerified)
Value Objects: EmailAddress, HashedPassword, UserProfile, Role
Step 4: Define Event Flows
Happy Path — Customer Places an Order:
1. Customer → Order Service: "Place Order"
Order aggregate transitions to SUBMITTED
Publishes: OrderPlaced { orderId, total, customerId, items[] }
2. OrderPlaced → Payment Service (async, via Kafka)
Payment aggregate created, status = PENDING
Calls Stripe API to authorize
Publishes: PaymentAuthorized { paymentId, orderId, amount }
3. PaymentAuthorized → Shipping Service (async)
Checks inventory for each item
Reserves stock (InventoryItem.reserve(qty))
Publishes: InventoryReserved { orderId, items[] }
4. InventoryReserved → Order Service (async)
Order status → CONFIRMED
Publishes: OrderConfirmed { orderId }
5. Warehouse staff picks and packs
Shipment status → DISPATCHED
Publishes: ShipmentDispatched { orderId, tracking, carrier }
6. ShipmentDispatched → Payment Service (async)
Captures the authorized payment
Publishes: PaymentCaptured { paymentId, orderId }
7. ShipmentDispatched → Order Service (async)
Order status → SHIPPED
Failure Path — Payment Fails:
2b. PaymentFailed → Order Service
Order status → PAYMENT_FAILED
Publishes: OrderCancelled { orderId, reason: PAYMENT_FAILED }
Failure Path — Inventory Unavailable:
3b. InventoryUnavailable → Payment Service
Reverses authorization
Publishes: PaymentVoided { paymentId }
3b. InventoryUnavailable → Order Service
Order status → CANCELLED
Publishes: OrderCancelled { orderId, reason: OUT_OF_STOCK }
DDD in System Design Interviews
When to Bring Up DDD
- "Design an e-commerce platform" — Start with bounded contexts (Catalog, Order, Payment, Shipping, User). This immediately shows structured thinking.
- "How would you split this monolith?" — Use event storming to identify domain events, then draw boundaries where the language changes.
- "How do these services communicate?" — Draw a context map showing upstream/downstream, events, and ACL patterns.
- "How do you handle data consistency?" — Explain aggregates as consistency boundaries and domain events for eventual consistency between services.
Common Mistakes
| Mistake | Why It's Wrong | DDD Approach |
|---|---|---|
| One service per entity | Too granular, too chatty, distributed monolith | One service per bounded context (may contain multiple aggregates) |
| Shared database | Breaks context independence, hidden coupling | Each context owns its database entirely |
| Universal "Product" model | God object, coupled contexts, impossible to evolve | Each context defines its own view of Product |
| Sync calls everywhere | Temporal coupling, cascading failures | Domain events for cross-context communication |
| Anemic domain model | Entities are just data bags with getters/setters | Rich domain model with behavior in entities |
Quick Reference Checklist
- ☐ Identify bounded contexts by business capability and language boundaries
- ☐ Define ubiquitous language for each context
- ☐ Draw context map showing all relationships (upstream/downstream, events, ACL)
- ☐ Design aggregates within each context (small, consistent, one per transaction)
- ☐ Define domain events that flow between contexts
- ☐ Each context gets its own database — no shared tables
- ☐ Use ACL when integrating with legacy or external systems
- ☐ Reference other aggregates by ID, not object
- ☐ Keep value objects immutable
- ☐ Use domain services for cross-aggregate operations
Summary
- DDD starts with the domain, not the database. Understand the business problem first, then let the domain model drive your architecture.
- Ubiquitous language eliminates miscommunication — use domain terms in code, APIs, and conversations.
- Bounded contexts are the most important concept for system design — they become your microservice boundaries. Each context owns its data and defines its own model.
- Context maps show how contexts relate — Shared Kernel, Customer–Supplier, Conformist, Anti-Corruption Layer, Open Host Service. The relationship type determines integration approach.
- Tactical patterns (Entities, Value Objects, Aggregates, Domain Events, Repositories, Domain Services) tell you how to model within a bounded context.
- Aggregates are consistency boundaries — enforce invariants within, communicate via events between.
- Event Storming is the fastest way to discover bounded contexts — put domain events on a timeline and look for where the language changes.
- In interviews: Identify bounded contexts first, then draw a context map, then discuss aggregates and event flows. This shows deeper understanding than "I'll use microservices."