Foundations ModeDeep Dive Mode

Focusing on ubiquitous language, core business entities, and practical aggregate boundaries. Switch to Deep Dive for hexagonal ports & adapters, event sourcing, and CQRS projections.Showing comprehensive hexagonal architectures, domain event stream mechanics, and aggregate snapshotting algorithms. Switch to Foundations for intuitive domain modeling.

1. The Core Problem & Philosophy: Anemic Models vs Rich Models

Every non-trivial system starts with a simple mental model: orders have items, a customer places them, and a warehouse ships them. Then the business rules arrive: an order cannot be cancelled once shipped, a line item cannot exceed a stock reservation, a discount applies only to the first ten units. Before long, these rules are scattered across service classes, SQL triggers, and controller validation, and no single place in the codebase can answer the question: what is an Order, really?

This is the Anemic Domain Model anti-pattern, named by Martin Fowler: your domain objects are little more than data bags with getters and setters, while all the behavior lives in procedural transaction scripts that reach into those bags and manipulate them. The database schema dictates the object graph, the services dictate the workflow, and the domain itself has no voice.

Beginner Concept: The Medical Team Analogy

In a hospital, doctors, nurses, and surgeons use a rigorous, unambiguous vocabulary (e.g. "tachycardia" vs "heart racing"). If developers and domain experts speak different languages, business requirements get lost in translation. DDD insists that code speaks the exact dialect of the business domain without technical jargon leaking into business rules.

Deep Dive: Strategic Mapping & Context Boundaries

Strategic DDD uses Context Maps to model organizational and technical interfaces between Bounded Contexts: Upstream/Downstream (U/D), Customer/Supplier, Anti-Corruption Layer (ACL) translation, Open Host Service (OHS), and Shared Kernel relationships.

Procedural Services vs Encapsulated DomainThe Core Shift
 ANEMIC / PROCEDURAL (logic leaks everywhere)

 ┌──────────────┐   ┌──────────────────────┐   ┌──────────────────┐
 │ OrderService │──▶│ order.setStatus(...) │   │ OrderController  │
 │ (orchestr.)  │   │ order.setTotal(...)  │──▶│ (validates in    │
 │              │   │ item.setQty(...)     │   │  the web layer)  │
 └──────┬───────┘   └──────────┬───────────┘   └────────┬─────────┘
        │                      │                       │
        ▼                      ▼                       ▼
 ┌─────────────────────────────────────────────────────────────┐
 │  Business rules duplicated in: services, triggers, UI, jobs  │
 └─────────────────────────────────────────────────────────────┘

 RICH / DOMAIN-CENTRIC (rules live with the data they govern)

 ┌────────────────────────────────────────────────────────────┐
 │                    Order (Aggregate Root)                   │
 │  • addItem(sku, qty)  → validates, computes, enforces      │
 │  • place()            → state machine: DRAFT → PLACED       │
 │  • cancel(reason)     → refuses if already shipped          │
 │  • total()            → derived, never stored redundantly   │
 └────────────────────────────────────────────────────────────┘
        ▲
        │  application service only: load → call → save
 ┌──────┴───────┐
 │ OrderService │  (thin orchestrator, no business rules)
 └──────────────┘

Anemic Model

Objects are getters/setters; behavior lives in services. The model cannot protect its own invariants, so every caller must remember the rules. Duplication is structural: three callers, three copies of the "cannot cancel shipped orders" check.

Rich Model

Objects encapsulate behavior and enforce their own rules. Callers invoke intent (order.cancel(reason)), not mechanics (order.setStatus("cancelled")). The invariant lives in exactly one place: the aggregate.

Procedural Flow

The workflow lives in a service that mutates state step by step. Easy to write, hard to reason about at scale, and nearly impossible to test in isolation without mocking the entire world.

Encapsulated Flow

The workflow lives inside the model as state machine transitions. Each method guards its preconditions, so invalid transitions are impossible by construction, not by convention.

The DDD premise:Software is a model of a business. If the model is a bag of data, the business rules will live in the plumbing (services, triggers, UI) and rot there. If the model is the business (with its vocabulary, rules, and invariants intact), then the plumbing becomes boring, mechanical, and replaceable. Domain-Driven Design is the discipline of making the model the center of the system.

2. Strategic Design: Carving the Problem Space

Strategic DDD answers the question where do we draw the boundaries? It operates at the level of the whole organization and its systems, before a single class is written. Three ideas anchor it: Subdomains (what the business actually does), Ubiquitous Language (one shared vocabulary), and Bounded Contexts (where each model is true).

2.1 Subdomains: Core, Supporting, Generic

A business is not one thing; it is a collection of subdomains, each with its own logic, complexity, and strategic value. The distinction drives where you invest modeling effort.

SubdomainStrategic ValueExample (E-Commerce)Build vs Buy
CoreDifferentiates you from competitors; the reason the business exists.Pricing & discount engine, order orchestration, recommendation logic.Build in-house, model deeply, invest the best engineers.
SupportingNeeded for the business to operate, but not differentiating.Order management workflow, customer profile management.Build, but keep it simple; standard patterns are fine.
GenericCommodity capability, identical to what everyone else uses.Authentication, payments, email delivery, inventory counts.Buy or use off-the-shelf; do not burn core talent.
Heuristic:If a subdomain is generic, a library or SaaS product is almost always the right answer. If it is core, that is where you apply the full DDD toolkit (aggregates, invariants, domain events), because that is where your business actually competes.

2.2 Ubiquitous Language

The Ubiquitous Language is a single, shared vocabulary used by domain experts, product managers, and developers alike: in meetings, in code, in schemas, and in tests. The goal is to eliminate the translation layer where a business person says "hold" and a developer hears "reserve" and a DBA writes status_flag = 2.

When the language is ubiquitous, the code reads like the business. A domain expert can look at order.place(), order.cancel(reason), and order.ship() and understand exactly what the system does. If a term in the code does not exist in the business's vocabulary, that is a smell; rename it.

Language drift is the first sign of trouble:If your team says order, cart, and purchase interchangeably for the same concept, the model will fragment. Pick one term per concept per context, and enforce it in code review and in the schema.

2.3 Bounded Contexts: Where a Model Is True

Here is the uncomfortable truth: a single "Product" model cannot serve Sales, Inventory, and Shipping at the same time. Each department uses the word "product" to mean something subtly different, with different rules, different attributes, and different lifecycles.

One Word, Three Models: The E-Commerce ProductBounded Contexts
 ┌──────────────────────────┐   ┌──────────────────────────┐   ┌──────────────────────────┐
 │  SALES CONTEXT           │   │  INVENTORY CONTEXT       │   │  SHIPPING CONTEXT        │
 │  "Product"               │   │  "Product"               │   │  "Product"               │
 ├──────────────────────────┤   ├──────────────────────────┤   ├──────────────────────────┤
 │  id                       │   │  id                      │   │  id                      │
 │  name                     │   │  sku                     │   │  sku                     │
 │  description              │   │  warehouse               │   │  weight                  │
 │  price  (Money)           │   │  stockOnHand             │   │  dimensions              │
 │  category                 │   │  reservedStock           │   │  hazmatClass             │
 │  isActive                 │   │  reorderPoint            │   │  fragile                 │
 │  rules: price > 0         │   │  rules: stock >= 0      │   │  rules: weight > 0       │
 └──────────────────────────┘   └──────────────────────────┘   └──────────────────────────┘
        ▲                                ▲                                ▲
        │  catalog team owns             │  warehouse team owns           │  logistics team owns
        │  the sales model               │  the inventory model           │  the shipping model

  SAME WORD "Product" vs DIFFERENT MEANING, DIFFERENT RULES, DIFFERENT OWNERS.
  A Bounded Context is the boundary inside which a model is unambiguous.

A Bounded Context is the boundary (organizational, linguistic, and technical) within which a particular model is defined and consistent. Inside the Sales context, Product has a price and a category. Inside Inventory, it has stock levels and a reorder point. Inside Shipping, it has weight and hazard class. These are three different models that happen to share a name.

Bounded Context

A logical boundary around a model, owned by one team, with its own ubiquitous language. It may span multiple services or be a single module; the boundary is about model consistency, not deployment topology.

Microservice

A deployment and scaling unit. A bounded context is frequently implemented as one or more microservices, but a small context may be a module inside a monolith. The two concepts are orthogonal.

Database Table

A storage structure. An aggregate is not a table: one aggregate may span several tables, and one table may hold data from several aggregates. Tables are a persistence concern; aggregates are a consistency concern.

Shared Model Trap

Sharing one "Product" entity across contexts couples their evolution: a price field change forces an inventory migration. Bounded contexts break that coupling by giving each context its own model and translating at the edges.


3. Context Mapping & Integration

Once you have drawn bounded contexts, you must decide how they talk to each other. A Context Map is the picture of those relationships. Each relationship has a direction (upstream/downstream) and a pattern that governs how models are translated at the boundary.

PatternRelationshipWhen to Use
Anti-Corruption Layer (ACL)Upstream → DownstreamIntegrating with a legacy system or third-party whose model you cannot change and do not want leaking into yours.
Shared KernelPeer / PeerTwo contexts share a small, stable subset of the model (e.g. a Money value object) and coordinate changes carefully.
Customer / SupplierUpstream → DownstreamThe upstream team prioritizes the downstream team's needs (e.g. an internal platform team serving the order context).
Published LanguageUpstream → DownstreamThe upstream publishes a well-documented, versioned contract (an API schema, an event schema) that downstream teams consume.
Separate WaysNoneThe contexts genuinely do not need to integrate; each solves its problem independently.
ConformistUpstream → DownstreamThe downstream accepts the upstream's model as-is because the cost of translation exceeds the cost of conformity.

3.1 The Anti-Corruption Layer (ACL)

The Anti-Corruption Layer is the most important integration pattern in DDD. When your pristine domain model must talk to a legacy system, a third-party API, or a database schema designed by someone who never heard of aggregates, the ACL is a translation boundary: it converts the foreign model into your domain model at the edge, so the corruption never reaches your core.

ACL: Translating a Legacy ERP into a Clean DomainIntegration Boundary
 LEGACY ERP (upstream)                ANTI-CORRUPTION LAYER              ORDER CONTEXT (downstream)
 ┌──────────────────────┐   ┌───────────────────────────────────┐   ┌──────────────────────────┐
 │ tbl_ORD_HDR          │   │  LegacyOrderRepository (adapter)  │   │  Order (Aggregate Root)  │
 │  ORD_ID     = 9007   │   │                                   │   │  id: OrderId            │
 │  ORD_STAT   = "P"    │──▶│  fetch()                          │   │  status: OrderStatus    │
 │  CUST_NBR   = 41     │   │  map HDR + ITM rows               │   │  lines: OrderLine[]     │
 │  ORD_AMT    = 71.98  │   │  translate "P" → Status.PLACED     │──▶│  total(): Money         │
 │ tbl_ORD_ITM          │   │  translate codes → domain values   │   │  place(), cancel(),     │
 │  ITM_SKU = 7734      │   │  validate invariants              │   │  ship()                 │
 │  ITM_QTY = 2         │   │  throw DomainException on garbage  │   └──────────────────────────┘
 └──────────────────────┘   └───────────────────────────────────┘
        ▲
        │  ugly, foreign, untouchable
        │  (nobody can change this schema)

 RULES:
 1. The domain NEVER sees tbl_ORD_HDR or status codes like "P".
 2. All translation, validation, and error mapping happens in the ACL.
 3. The ACL is owned by the downstream team: it is YOUR defense.
ACL rules of thumb:The ACL is owned by the downstream team: it is your defense, so you write it. It should translate before the foreign data reaches the domain, not after. If the foreign system emits garbage, the ACL rejects it with a domain exception rather than letting a null or a magic code flow into an aggregate.

3.2 Shared Kernel, Customer/Supplier & Published Language

Shared Kernel is the pragmatic exception to "no shared models": two teams agree on a small, stable set of shared types (value objects like Money, SKU, Currency) and coordinate changes through explicit review. The kernel must stay tiny; the moment it grows, it becomes a coupling liability.

Customer/Supplier describes a relationship where the upstream team (supplier) is accountable to the downstream team (customer): the supplier plans its roadmap around the customer's needs, and both teams agree on acceptance tests for the contract.

Published Language is the contract itself: a versioned, documented schema (an OpenAPI spec, an Avro/Protobuf event schema) that the upstream publishes and the downstream consumes. It is the lingua franca between contexts, and it is why event-driven systems use schemas, not shared code, as their integration point.


4. Tactical Modeling: Building Blocks

Strategic design draws the map; tactical modeling fills in the territory. These are the building blocks you use inside a bounded context: Value Objects, Entities, Aggregates, and Domain Events. We will model them in pure TypeScript, using an e-commerce Order Management & Inventory case study.

4.1 Value Objects: Immutability & Structural Equality

A Value Object is defined entirely by its attributes, not by an identity. Two Money instances of $10.00 are the same value. Value objects are immutable (every operation returns a new instance), and they validate themselves at construction, so an invalid value cannot exist in your system.

Value Objects: Money, SKU, Quantity (TypeScript)TypeScript
export class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string,
  ) {
    if (!Number.isFinite(amount)) throw new Error("amount must be finite");
    if (amount < 0) throw new Error("amount cannot be negative");
    if (currency.length !== 3) throw new Error("currency must be ISO 4217");
  }

  static of(amount: number, currency = "USD"): Money {
    return new Money(Math.round(amount * 100) / 100, currency);
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return Money.of(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  private assertSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error(`currency mismatch: ${this.currency} vs ${other.currency}`);
    }
  }
}

export class SKU {
  private constructor(readonly value: string) {
    if (!/^[A-Z0-9]-[A-Z0-9]{4}$/.test(value)) {
      throw new Error(`invalid SKU: ${value}`);
    }
  }
  static of(value: string): SKU { return new SKU(value); }
  equals(other: SKU): boolean { return this.value === other.value; }
}

export class Quantity {
  private constructor(readonly units: number) {
    if (!Number.isInteger(units) || units < 0) {
      throw new Error("quantity must be a non-negative integer");
    }
  }
  static of(units: number): Quantity { return new Quantity(units); }
  add(other: Quantity): Quantity { return Quantity.of(this.units + other.units); }
}

Immutability

A value object never changes after construction. money.add(...) returns a new Money; it never mutates the receiver. This makes value objects safe to share across threads, cache aggressively, and reason about without aliasing bugs.

Structural Equality

Two value objects are equal when all their attributes are equal; no ID required. Money.of(10).equals(Money.of(10)) is true. This is why they make perfect map keys and why they can be compared freely in tests.

Self-Validation

The constructor is the only gate. An invalid Money (negative, non-finite) or an invalid SKU (wrong format) cannot be constructed. The type system plus the constructor make illegal states unrepresentable.

Behavioral Methods

Value objects carry their own operations: add, multiply, assertSameCurrency. Arithmetic on money is defined in money, not scattered across services.

4.2 Entities: Identity & Lifecycle

An Entity is defined by its identity, not its attributes. Two OrderLineItem instances with identical fields are different if they have different IDs. Entities have a lifecycle (they are created, changed, and eventually removed), and their identity must remain stable across that lifecycle, even as every attribute changes.

Entity: OrderLineItem with Stable IdentityTypeScript
export class OrderLineItem {
  private constructor(
    readonly id: string,          // stable identity, never changes
    readonly sku: SKU,
    private _quantity: Quantity,
    private _unitPrice: Money,
  ) {}

  static create(id: string, sku: SKU, quantity: Quantity, unitPrice: Money): OrderLineItem {
    if (quantity.units === 0) throw new Error("line item must have quantity > 0");
    return new OrderLineItem(id, sku, quantity, unitPrice);
  }

  get quantity(): Quantity { return this._quantity; }
  get unitPrice(): Money { return this._unitPrice; }

  total(): Money { return this._unitPrice.multiply(this._quantity.units); }

  changeQuantity(newQuantity: Quantity): void {
    if (newQuantity.units === 0) throw new Error("cannot set quantity to zero; remove the line instead");
    this._quantity = newQuantity;
  }

  equalsIdentity(other: OrderLineItem): boolean {
    return this.id === other.id;
  }
}
Identity vs Equality:For a value object, equality is structural: Money.of(5).equals(Money.of(5)). For an entity, equality is identity: two line items with the same fields but different ids are different entities. Mixing these up is a classic source of subtle bugs; always implement equals on entities by identity, and on value objects by structure.

4.3 Domain Events

A Domain Event records that something important happened in the domain, in the past tense: OrderPlacedEvent, StockAllocatedEvent. Events are how aggregates communicate without coupling, and they are the backbone of CQRS and eventual consistency (Section 7).

Domain Event: OrderPlacedEventTypeScript
export interface DomainEvent {
  readonly eventId: string;
  readonly occurredAt: Date;
}

export class OrderPlacedEvent implements DomainEvent {
  constructor(
    readonly eventId: string,
    readonly occurredAt: Date,
    readonly orderId: string,
    readonly customerId: string,
    readonly total: Money,
    readonly lineItems: ReadonlyArray<{ sku: SKU; quantity: Quantity }>,
  ) {}
}

export class StockAllocatedEvent implements DomainEvent {
  constructor(
    readonly eventId: string,
    readonly occurredAt: Date,
    readonly orderId: string,
    readonly sku: SKU,
    readonly quantity: Quantity,
  ) {}
}

Notice what the event does not carry: no repository references, no callbacks, no behavior. It is a plain, immutable record of a fact. Downstream contexts subscribe to it and build their own read models or trigger their own workflows, without the order context knowing they exist.


5. Aggregates & Invariant Enforcement

An Aggregate is a cluster of domain objects (entities and value objects) that must be changed together, treated as a single unit for data changes. Each aggregate has one Aggregate Root, the only object external code may hold a reference to. Everything inside the boundary is reached through the root.

The Order Aggregate: One Consistency BoundaryConsistency Boundary
 ┌───────────────────────────────────────────────────────────────┐
 │                    ORDER (Aggregate Root)                     │
 │  id: OrderId                                                  │
 │  status: DRAFT | PLACED | PAID | SHIPPED | CANCELLED          │
 │  customerId: CustomerId                                        │
 │  ┌─────────────────────────────────────────────────────────┐  │
 │  │  lines: OrderLineItem[]   (Entities, inside the root)   │  │
 │  │   • sku, quantity, unitPrice                             │  │
 │  │   • total() derived from lines                           │  │
 │  └─────────────────────────────────────────────────────────┘  │
 │                                                               │
 │  INVARIANTS (enforced by the root):                           │
 │  • max 20 line items per order                                │
 │  • cannot place an order with zero lines                      │
 │  • cannot cancel an order once SHIPPED                        │
 │  • cannot add lines after PLACED                              │
 └───────────────────────────────────────────────────────────────┘
        ▲
        │  external code may ONLY hold the root.
        │  line items are reached via order.lines, never directly.

  WHY? The root is the single gatekeeper. If anyone could mutate a
  line item directly, the "max 20 items" invariant could be bypassed.
  The boundary makes the invariant enforceable.

5.1 The 1-Aggregate-Per-Transaction Rule

The most important rule in aggregate design: one aggregate per transaction. A single transaction may modify exactly one aggregate instance. If two aggregates must change atomically, you have either drawn the boundary wrong (they should be one aggregate) or you need a saga / process manager that coordinates them through events, accepting eventual consistency between them.

The rule exists because a transaction that spans aggregates effectively merges them into one giant consistency boundary, and a giant boundary means lock contention, low concurrency, and the "giant aggregate" anti-pattern (Section 8).

Cross-aggregate references must be by ID, not by object:An Order should reference a customer by customerId, not by holding a Customer object graph. Holding objects drags the customer's entire aggregate into the order's consistency boundary and couples their lifecycles. IDs keep aggregates independent; the repository rehydrates them on demand.

5.2 State Machines & Invariant Enforcement

Aggregates are often state machines: the status field transitions through a defined set of states, and each transition guards its preconditions. The aggregate root is the only place these transitions can happen, so invalid transitions are impossible by construction.

Order Aggregate Root: Invariants & State MachineTypeScript
export type OrderStatus = "DRAFT" | "PLACED" | "PAID" | "SHIPPED" | "CANCELLED";

const MAX_LINE_ITEMS = 20;

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    private _status: OrderStatus,
    private readonly _lines: OrderLineItem[] = [],
    private readonly _events: DomainEvent[] = [],
  ) {}

  static create(id: string, customerId: string): Order {
    return new Order(id, customerId, "DRAFT");
  }

  get status(): OrderStatus { return this._status; }
  get lines(): ReadonlyArray<OrderLineItem> { return this._lines; }
  get events(): ReadonlyArray<DomainEvent> { return this._events; }

  addLine(sku: SKU, quantity: Quantity, unitPrice: Money): void {
    this.assertStatus("DRAFT", "cannot add lines after the order is placed");
    if (this._lines.length >= MAX_LINE_ITEMS) {
      throw new Error(`order cannot exceed ${MAX_LINE_ITEMS} line items`);
    }
    const existing = this._lines.find((l) => l.sku.equals(sku));
    if (existing) {
      existing.changeQuantity(existing.quantity.add(quantity));
    } else {
      this._lines.push(OrderLineItem.create(crypto.randomUUID(), sku, quantity, unitPrice));
    }
  }

  place(): void {
    this.assertStatus("DRAFT", "only a draft order can be placed");
    if (this._lines.length === 0) {
      throw new Error("cannot place an order with no line items");
    }
    this._status = "PLACED";
    this._events.push(new OrderPlacedEvent(
      crypto.randomUUID(), new Date(), this.id, this.customerId, this.total(), this._lines,
    ));
  }

  cancel(reason: string): void {
    if (this._status === "SHIPPED") {
      throw new Error("cannot cancel an order that has been shipped");
    }
    if (this._status === "CANCELLED") throw new Error("order is already cancelled");
    this._status = "CANCELLED";
  }

  ship(): void {
    this.assertStatus("PAID", "only a paid order can be shipped");
    this._status = "SHIPPED";
  }

  markPaid(): void {
    this.assertStatus("PLACED", "only a placed order can be paid");
    this._status = "PAID";
  }

  total(): Money {
    return this._lines.reduce((sum, line) => sum.add(line.total()), Money.of(0));
  }

  private assertStatus(expected: OrderStatus, message: string): void {
    if (this._status !== expected) throw new Error(message);
  }
}

Invariants Live in the Root

The "max 20 items" and "cannot place an empty order" rules are enforced inside the aggregate. No service, controller, or ORM can bypass them, because the only way to change an order is through the root's methods.

State Machine Guards

Each transition (place, markPaid, ship, cancel) asserts its precondition. cancel() on a shipped order throws; ship() on a draft order throws. Illegal transitions are impossible, not merely discouraged.

Events Recorded, Not Sent

The aggregate appends OrderPlacedEvent to an internal list. It does not publish it; publishing is an infrastructure concern handled by the application layer after the transaction commits (Section 6).

IDs, Not Object Graphs

Order holds customerId, not a Customer. The line items are value-object-plus-entity children inside the boundary, but the customer is outside it, reached by ID through a repository.

Aggregate sizing heuristic:A good aggregate is as small as possible while still protecting its invariants atomically. If two objects never need to change in the same transaction, they are probably separate aggregates. If they always change together, they are one. Start small; grow only when an invariant forces it.

6. Ports & Adapters (Hexagonal Architecture)

DDD tells you what to model; Hexagonal Architecture (Alistair Cockburn) tells you where to put it. The idea: the domain core sits at the center, completely ignorant of the outside world. Everything else (HTTP, databases, message queues, UIs) is an adapter plugged into a port.

Hexagonal / Ports & AdaptersDependency Inversion
                    ┌────────────────────────────────────────────┐
                    │          PRIMARY (DRIVING) SIDE            │
                    │   REST API    GraphQL    CLI    Message    │
                    │   (adapter)   (adapter) (adapter) (cons.) │
                    └──────┬──────────┬──────────┬───────────────┘
                           │          │          │
                           ▼          ▼          ▼
                    ┌────────────────────────────────────────────┐
                    │          APPLICATION SERVICES              │
                    │   PlaceOrder, CancelOrder, ShipOrder       │
                    │   (orchestrate: load → call → save → emit) │
                    └───────────────────┬────────────────────────┘
                                        │  depends on ports (interfaces)
                                        ▼
                    ┌────────────────────────────────────────────┐
                    │               DOMAIN CORE                   │
                    │   Aggregates · Value Objects · Domain       │
                    │   Services · Domain Events                  │
                    │   (pure business logic, zero I/O)           │
                    └───────────────────┬────────────────────────┘
                                        │  implements ports
                                        ▼
                    ┌────────────────────────────────────────────┐
                    │          SECONDARY (DRIVEN) SIDE            │
                    │   IOrderRepository   IEventPublisher        │
                    │   (port)             (port)                 │
                    │   PostgreSQL  MongoDB   Kafka    SQS        │
                    │   (adapter)   (adapter) (adapter)(adapter) │
                    └────────────────────────────────────────────┘

  ARROW RULE: dependencies point INWARD. The domain knows nothing
  about HTTP, SQL, or Kafka. Adapters implement ports; the core
  defines them.

6.1 Application Services vs Domain Services

Application Services (a.k.a. use-case handlers) are the orchestrators on the primary side. They are thin: load the aggregate from a repository, invoke a domain method, save the aggregate, publish the resulting events, and return a DTO. They contain no business rules; all rules live in the domain.

Domain Services are different: they hold business logic that does not naturally belong to a single aggregate or value object; for example, a PricingService that computes a discount across multiple line items, or a StockAvailabilityPolicy that checks allocation across warehouses. They are part of the domain core and operate on aggregates via their public methods.

Application ServiceDomain Service
LayerOutside the domain core (primary side)Inside the domain core
Contains business rules?No; only orchestrationYes; domain logic without a natural aggregate home
Depends onRepositories, publishers, transaction boundariesOnly domain objects and other domain services
ExamplePlaceOrderUseCase (load, call, save, emit)PricingService (discount computation)

6.2 Output Ports: Repositories & Event Publishers

The domain core defines ports (interfaces) for everything it needs from the outside: persistence and event publishing. The infrastructure provides adapters that implement those interfaces. The dependency points inward; the core depends on the interface, never on PostgreSQL or Kafka directly.

Output Ports: IOrderRepository, IEventPublisherTypeScript
// ── PORT (defined in the domain core) ──────────────────────────
export interface IOrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

export interface IEventPublisher {
  publish(event: DomainEvent): Promise<void>;
}

// ── APPLICATION SERVICE (primary side, thin orchestrator) ─────
export class PlaceOrderUseCase {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly events: IEventPublisher,
  ) {}

  async execute(orderId: string, customerId: string): Promise<void> {
    const order = await this.orders.findById(orderId);
    if (!order) throw new Error("order not found");

    order.place();                      // domain rule, inside the aggregate

    await this.orders.save(order);      // persist within the same transaction
    for (const event of order.events) {
      await this.events.publish(event); // publish AFTER commit
    }
  }
}

// ── ADAPTER (infrastructure, implements the port) ──────────────
export class PostgresOrderRepository implements IOrderRepository {
  constructor(private readonly pool: Pool) {}

  async save(order: Order): Promise<void> {
    // translate the aggregate into rows, run in a transaction
  }

  async findById(id: string): Promise<Order | null> {
    // rehydrate the aggregate from rows, reconstruct invariants
  }
}

export class KafkaEventPublisher implements IEventPublisher {
  constructor(private readonly producer: KafkaProducer) {}
  async publish(event: DomainEvent): Promise<void> {
    await this.producer.send({ topic: event.constructor.name, messages: [{ value: JSON.stringify(event) }] });
  }
}
Why this matters:Because the use case depends only on IOrderRepository and IEventPublisher, you can swap PostgreSQL for MongoDB, Kafka for SQS, or the real queue for an in-memory fake in tests, without touching the domain or the use case at all. The domain is testable in pure memory, and the infrastructure is replaceable.
Hexagonal Architecture: Driving vs Driven Ports & Dependency InversionArchitecture Spec

In Hexagonal / Clean Architecture, dependencies point exclusively inward towards domain core invariants:

 [ Primary / Driving Adapters ] ──▶ [ Driving Ports (Use Cases) ]
                                          │
                                          ▼
                                 [ DOMAIN CORE (Aggregates) ]
                                          │
                                          ▼
 [ Secondary / Driven Adapters ] ◀── [ Driven Ports (Interfaces) ]

The Domain Core contains zero references to HTTP, SQL, ORM decorators, or message broker drivers.


7. CQRS & Event-Driven Decoupling

Command Query Responsibility Segregation (CQRS) splits the model into two: a write side that executes commands against aggregates (preserving invariants), and a read side that serves queries from lightweight projections (optimized for the exact screens the UI needs). They are not the same model, and they do not have to use the same storage.

Write Aggregates vs Read ProjectionsCQRS Flow
  WRITE SIDE (commands)                          READ SIDE (queries)
  ─────────────────────                          ─────────────────────
  POST /orders/9007/place                        GET /orders/9007
        │                                               ▲
        ▼                                               │
  ┌──────────────────┐   ┌──────────────┐   ┌──────────────────────┐
  │ PlaceOrderUseCase│──▶│ OrderAggregate│   │ OrderReadModel (denorm)│
  │ (application svc)│   │ (invariants)  │   │  • status, total      │
  └────────┬─────────┘   └──────┬───────┘   │  • lineItems joined    │
           │                    │            │  • customerName        │
           │   OrderPlacedEvent │            └──────────────────────┘
           │                    ▼                    ▲
           │            ┌──────────────┐            │
           └───────────▶│ Event Bus     │────────────┤  projection
                        │ (Kafka/SQS)  │            │  subscribes to
                        └──────────────┘            │  OrderPlacedEvent
                                                     │  and rebuilds the
                                                     │  read model

  The write side NEVER serves reads. The read side NEVER enforces
  invariants. They meet only through events, asynchronously.
Event Sourcing: Aggregate State Replay (fold / reduce) & Snapshot FrequencyEvent Sourcing

In Event Sourcing, current state $S$ is computed by left-folding the ordered event stream:

 State = reduce(events, (state, event) => apply(state, event), InitialState)

 1. Load: Fetch all historical events WHERE aggregate_id = "ord_9007" ORDER BY sequence_num.
 2. Fold: Sequentially apply OrderCreated → ItemAdded → OrderPlaced → Current State.
 3. Mutate: Execute order.cancel(reason), producing OrderCancelledEvent.
 4. Append: Insert OrderCancelledEvent with expected_version = 3 (Optimistic Concurrency).
 5. Snapshot: Every 100 events, write a state snapshot to truncate replay duration.

7.1 Why Separate the Sides?

A single model that both enforces invariants and serves every query is a compromise in both directions. The write side wants normalized, invariant-protecting aggregates; the read side wants denormalized, pre-joined, screen-shaped data. CQRS lets each side be optimal for its job.

ConcernWrite Side (Commands)Read Side (Queries)
ShapeAggregates with invariantsDenormalized projections
ConsistencyStrong (within the aggregate)Eventual (updated by projections)
ScaleOptimized for correctness & concurrencyOptimized for query patterns, cacheable, replicable
StorageCan be a normalized SQL storeCan be a read replica, Elasticsearch, or a cache
LanguageVerbs: PlaceOrder, CancelOrderNouns: OrderSummary, OrderDetail

7.2 Eventual Consistency Across Contexts

When the order context places an order, the inventory context must allocate stock, but they are separate aggregates, possibly separate services. The order aggregate emits OrderPlacedEvent; the inventory context subscribes and runs its own StockAllocatedEvent flow. The two contexts are eventually consistent: the stock allocation happens a moment after the order is placed, not atomically with it.

Eventual consistency is a contract, not a bug:The moment you accept that two aggregates can change in separate transactions, you accept that a reader may briefly see an intermediate state. The design must make that acceptable: show "processing" states, retry failed projections, and make the read model idempotent (replaying an event must not double-apply it).
Idempotency is mandatory:Event delivery is at-least-once in practice. A projection that processes OrderPlacedEvent twice must produce the same result as processing it once, typically by keying the projection on the event's orderId and deduplicating, or by making the update a pure upsert.

8. Anti-Patterns & the Production Decision Matrix

DDD is a powerful tool, and like any powerful tool, it is easy to misuse. This final section covers the most common failure modes and, more importantly, when not to use DDD at all.

8.1 Anti-Pattern Checklist

Anti-PatternSymptomFix
Anemic Domain ModelEntities are getters/setters; all logic in services.Move rules into the aggregate; make services thin orchestrators.
Giant AggregateOne aggregate touches half the system; every write locks everything.Split by true consistency needs; use IDs for cross-aggregate references; accept eventual consistency.
ORM Entity LeakageDomain objects are JPA/TypeORM entities; schema changes force domain changes.Keep the domain pure; map to/from persistence in repository adapters.
Cross-Aggregate DB JoinsQueries join across aggregate boundaries, re-coupling what you split.Serve reads from projections/read models, not from aggregate tables.
Shared Model EverywhereOne Product entity used by every context.Give each bounded context its own model; translate at the ACL.
DDD Ceremony on CRUDAggregates, events, and repositories for a simple settings table.Use plain CRUD; DDD pays off only where invariants and complexity exist.

8.2 When to Use DDD vs Plain CRUD

SignalPrefer DDDPrefer CRUD / Transaction Script
Business rulesMany, evolving, interdependent invariantsFew or trivial rules; mostly data entry
Domain expertsAvailable, engaged, with rich vocabularyRules are fully specified in a requirements doc
Team sizeMultiple teams sharing a complex domainSmall team, small scope
Change frequencyRules change often; model must absorb themStable, well-understood requirements
ConcurrencyMultiple actors mutate the same dataSingle-writer or append-only workloads
Strategic valueCore subdomain where you competeSupporting or generic subdomain

8.3 Adoption Heuristics

DDD is not all-or-nothing. You can adopt it incrementally, exactly where it pays off:

Start with the Core

Apply aggregates and invariants to your core subdomain first (the pricing engine, the order orchestration), and leave generic subdomains on plain CRUD. DDD is a scalpel, not a blanket.

Draw the Map Early

Strategic design (bounded contexts, context map) pays off before any code: it tells you where the seams are, what to build, what to buy, and where the translation layers go. Do this even if you never write an aggregate.

Keep the Kernel Tiny

If you share a kernel, keep it to a handful of stable value objects. Every shared type is a coupling point; audit it at every design review.

Measure the Complexity

If a feature can be expressed as a few CRUD endpoints with no invariants, DDD ceremony is overhead. Reserve the full toolkit for the places where invariants actually fight back.

Key Takeaways

  1. Model the business, not the database. An anemic model pushes rules into procedural services where they rot; a rich model encapsulates them where they can be enforced.
  2. Draw boundaries before writing code. Subdomains tell you where to invest, and bounded contexts tell you where one model ends and another begins.
  3. Translate at the edges. Anti-Corruption Layers keep legacy and third-party models from corrupting your domain; published languages keep contexts decoupled.
  4. Value objects are immutable and self-validating; entities have identity. Aggregates protect invariants, and one aggregate changes per transaction.
  5. Depend inward. Hexagonal architecture keeps the domain pure and the infrastructure replaceable; CQRS lets the read side be shaped for queries without compromising the write side.
  6. Use DDD where it pays. Core subdomains with real invariants justify the ceremony; CRUD and generic subdomains do not.