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.
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.
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.
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.
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.
| Subdomain | Strategic Value | Example (E-Commerce) | Build vs Buy |
|---|---|---|---|
| Core | Differentiates you from competitors; the reason the business exists. | Pricing & discount engine, order orchestration, recommendation logic. | Build in-house, model deeply, invest the best engineers. |
| Supporting | Needed for the business to operate, but not differentiating. | Order management workflow, customer profile management. | Build, but keep it simple; standard patterns are fine. |
| Generic | Commodity capability, identical to what everyone else uses. | Authentication, payments, email delivery, inventory counts. | Buy or use off-the-shelf; do not burn core talent. |
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.
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.
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ 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.
| Pattern | Relationship | When to Use |
|---|---|---|
| Anti-Corruption Layer (ACL) | Upstream → Downstream | Integrating with a legacy system or third-party whose model you cannot change and do not want leaking into yours. |
| Shared Kernel | Peer / Peer | Two contexts share a small, stable subset of the model (e.g. a Money value object) and coordinate changes carefully. |
| Customer / Supplier | Upstream → Downstream | The upstream team prioritizes the downstream team's needs (e.g. an internal platform team serving the order context). |
| Published Language | Upstream → Downstream | The upstream publishes a well-documented, versioned contract (an API schema, an event schema) that downstream teams consume. |
| Separate Ways | None | The contexts genuinely do not need to integrate; each solves its problem independently. |
| Conformist | Upstream → Downstream | The 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.
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.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.
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.
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;
}
}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).
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.
┌───────────────────────────────────────────────────────────────┐
│ 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).
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.
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.
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.
┌────────────────────────────────────────────┐
│ 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 Service | Domain Service | |
|---|---|---|
| Layer | Outside the domain core (primary side) | Inside the domain core |
| Contains business rules? | No; only orchestration | Yes; domain logic without a natural aggregate home |
| Depends on | Repositories, publishers, transaction boundaries | Only domain objects and other domain services |
| Example | PlaceOrderUseCase (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.
// ── 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) }] });
}
}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 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.
| Concern | Write Side (Commands) | Read Side (Queries) |
|---|---|---|
| Shape | Aggregates with invariants | Denormalized projections |
| Consistency | Strong (within the aggregate) | Eventual (updated by projections) |
| Scale | Optimized for correctness & concurrency | Optimized for query patterns, cacheable, replicable |
| Storage | Can be a normalized SQL store | Can be a read replica, Elasticsearch, or a cache |
| Language | Verbs: PlaceOrder, CancelOrder | Nouns: 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.
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-Pattern | Symptom | Fix |
|---|---|---|
| Anemic Domain Model | Entities are getters/setters; all logic in services. | Move rules into the aggregate; make services thin orchestrators. |
| Giant Aggregate | One 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 Leakage | Domain objects are JPA/TypeORM entities; schema changes force domain changes. | Keep the domain pure; map to/from persistence in repository adapters. |
| Cross-Aggregate DB Joins | Queries join across aggregate boundaries, re-coupling what you split. | Serve reads from projections/read models, not from aggregate tables. |
| Shared Model Everywhere | One Product entity used by every context. | Give each bounded context its own model; translate at the ACL. |
| DDD Ceremony on CRUD | Aggregates, 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
| Signal | Prefer DDD | Prefer CRUD / Transaction Script |
|---|---|---|
| Business rules | Many, evolving, interdependent invariants | Few or trivial rules; mostly data entry |
| Domain experts | Available, engaged, with rich vocabulary | Rules are fully specified in a requirements doc |
| Team size | Multiple teams sharing a complex domain | Small team, small scope |
| Change frequency | Rules change often; model must absorb them | Stable, well-understood requirements |
| Concurrency | Multiple actors mutate the same data | Single-writer or append-only workloads |
| Strategic value | Core subdomain where you compete | Supporting 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
- 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.
- Draw boundaries before writing code. Subdomains tell you where to invest, and bounded contexts tell you where one model ends and another begins.
- Translate at the edges. Anti-Corruption Layers keep legacy and third-party models from corrupting your domain; published languages keep contexts decoupled.
- Value objects are immutable and self-validating; entities have identity. Aggregates protect invariants, and one aggregate changes per transaction.
- 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.
- Use DDD where it pays. Core subdomains with real invariants justify the ceremony; CRUD and generic subdomains do not.
