← All Posts
High Level Design Series · Architecture Patterns · Part 4· Post 40 of 70

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:

When to use DDD: DDD shines in complex domains (finance, healthcare, logistics, e-commerce). For simple CRUD apps with minimal business logic, DDD adds unnecessary ceremony. The litmus test: if your business rules can be expressed as "save data, query data," you probably don't need DDD. If domain experts spend hours explaining edge cases and business rules, DDD will pay for itself.

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 LanguageWith Ubiquitous Language
user.purchaseHistorycustomer.orderHistory
processTransaction()placeOrder()
item.status = 3order.markAsShipped()
updateRecord(userId, data)customer.changeShippingAddress(newAddress)
doPayment(amount)payment.authorize(orderTotal)

Rules for Ubiquitous Language

  1. Use domain terms in code. If the domain expert says "Order," the class is Order, not PurchaseRecord.
  2. No synonyms. Pick one term and use it everywhere. If it's "Shipment," don't also call it "Delivery" or "Dispatch."
  3. 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).
  4. 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 ContextCore Concepts"Product" Means…
CatalogProduct, Category, Brand, ProductImage, ReviewA browseable item with name, description, images, price, and reviews
OrderOrder, OrderLine, ShippingAddress, OrderStatusA line item with SKU, quantity, and price snapshot at time of purchase
PaymentPayment, PaymentMethod, Authorization, Refund, CreditNoteN/A — Payment doesn't care about products, only order totals
ShippingShipment, Carrier, TrackingNumber, DeliveryEstimate, PackageA physical item with weight, dimensions, and handling requirements
User / IdentityUser, Account, Credentials, Profile, RoleN/A — User context knows about authentication, not products
Key insight: "Product" exists in multiple contexts but means entirely different things. The Catalog's Product has images and reviews. The Shipping's Product has weight and dimensions. These are not the same class — they belong to different models in different bounded contexts. Trying to create a single universal 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

PatternPower DynamicCouplingReal-World Example
Shared KernelEqual partnershipHigh (shared code)Two teams sharing Money/Address types
Customer–SupplierUpstream serves downstreamMediumCatalog → Order, User → all services
ConformistUpstream dictatesHigh (no translation)Using Stripe/FedEx API as-is
Anti-Corruption LayerDownstream protects itselfLow (translated)Wrapping a legacy inventory system
Open Host / Published Lang.Upstream serves manyLow (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();
    }
}
Entity vs Value Object decision tree:
  • 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
Examples: 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

  1. The root entity is the only member accessible from outside. External code cannot hold a reference to an internal entity.
  2. Invariants are enforced within the aggregate boundary. The aggregate root ensures that business rules are never violated.
  3. Transactions don't span aggregates. Each aggregate is a consistency boundary — save one aggregate per transaction.
  4. Reference other aggregates by ID, not by object reference. An Order holds customerId: CustomerId, not customer: Customer.
  5. 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

PatternWhat It IsE-Commerce Example
EntityIdentity + lifecycle + behaviorOrder, Customer, Product
Value ObjectImmutable, attribute-based equalityMoney, Address, Email, SKU
AggregateConsistency boundary, transactional unitOrder (root) + OrderLines
Domain EventSomething that happened in the domainOrderPlaced, PaymentCaptured
RepositoryCollection-like persistence abstractionOrderRepository, ProductRepository
Domain ServiceStateless cross-entity operationsPricingService, 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 ConceptMicroservice EquivalentWhy
Bounded ContextMicroserviceIndependent deployable with its own data
Context MapService integration architectureShows how services communicate
Ubiquitous LanguageAPI contract / Protobuf schemaShared vocabulary in API surface
Domain EventAsync message (Kafka/RabbitMQ)Loose coupling between services
Anti-Corruption LayerAPI Gateway / Adapter serviceProtects from external models
AggregateService's internal consistency boundaryTransaction scope within a service
Shared KernelShared 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
Interview tip: When asked "How would you decompose this system into microservices?", start by identifying bounded contexts. Ask: "What are the distinct business capabilities?" and "Where does the ubiquitous language change?" This approach is far more convincing than "I'd split it by database table" or "one service per entity."

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

  1. 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."
  2. Phase 2 — Enforce Timeline: Arrange events in chronological order. Duplicates are merged. Gaps in the timeline reveal missing events.
  3. 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").
  4. Phase 4 — Identify Aggregates: Group commands and events around the entities they affect. Add yellow aggregate notes ("Order," "Payment," "Shipment").
  5. 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"
Key discoveries from event storming:
  • "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

ContextResponsibilityAggregatesKey Events Published
CatalogBrowse & search productsProduct, CategoryProductAdded, PriceChanged, ProductDiscontinued
OrderCart → checkout → order lifecycleOrder, CartOrderPlaced, OrderCancelled, OrderExpired
PaymentAuthorize, capture, refundPayment, RefundPaymentAuthorized, PaymentCaptured, RefundIssued
ShippingInventory, pick/pack, dispatch, trackShipment, Inventory, PackageInventoryReserved, ShipmentDispatched, ShipmentDelivered
User / IdentityAuth, profiles, preferencesUser, AccountAccountCreated, 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

Common Mistakes

MistakeWhy It's WrongDDD Approach
One service per entityToo granular, too chatty, distributed monolithOne service per bounded context (may contain multiple aggregates)
Shared databaseBreaks context independence, hidden couplingEach context owns its database entirely
Universal "Product" modelGod object, coupled contexts, impossible to evolveEach context defines its own view of Product
Sync calls everywhereTemporal coupling, cascading failuresDomain events for cross-context communication
Anemic domain modelEntities are just data bags with getters/settersRich domain model with behavior in entities

Quick Reference Checklist

  1. ☐ Identify bounded contexts by business capability and language boundaries
  2. ☐ Define ubiquitous language for each context
  3. ☐ Draw context map showing all relationships (upstream/downstream, events, ACL)
  4. ☐ Design aggregates within each context (small, consistent, one per transaction)
  5. ☐ Define domain events that flow between contexts
  6. ☐ Each context gets its own database — no shared tables
  7. ☐ Use ACL when integrating with legacy or external systems
  8. ☐ Reference other aggregates by ID, not object
  9. ☐ Keep value objects immutable
  10. ☐ Use domain services for cross-aggregate operations

Summary