Domain-Driven Design: A Practical Introduction for Developers
A practical guide to Domain-Driven Design: Strategic Bounded Contexts, Context Mapping, Subdomains, tactical Aggregates, Value Objects, Domain Events, and TypeScript implementation.

Domain-Driven Design: A Practical Introduction for Developers
Software applications rarely collapse because an engineer picked the wrong web framework or because a database engine lacked indexing capabilities. They degrade because, over months and years of iteration, the distance between the actual business domain and the structure of the codebase expands into an unmanageable chasm.
In a conventional database-centric system, development begins by drafting relational database tables and Object-Relational Mapping (ORM) models. Business rules get scattered across frontend forms, HTTP controllers, procedural transaction scripts, database triggers, and ORM lifecycle hooks. Over time, words lose their meaning: an "Account" in an authentication table is conflated with an "Account" in the billing ledger; an "Order" in checkout is forced to contain 150 nullable columns to support post-fulfillment warehouse tracking. Every new feature requires navigating a minefield of unintended side effects, and automated tests require massive integration environments just to verify basic business calculations.
Domain-Driven Design (DDD), originally formulated by Eric Evans in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software, provides an architectural framework to solve this problem.
DDD asserts that the primary driver of software architecture must be the mental model of the business domain itself, framed within explicit linguistic and transactional boundaries.
This guide breaks down the core concepts of Domain-Driven Design from an engineering perspective:
- How to divide complex systems using Strategic Design (Ubiquitous Language, Bounded Contexts, Subdomain classification, and Context Mapping).
- How to build resilient, encapsulated business logic using Tactical Design (Entities, Value Objects, Aggregates, Domain Events, and Repositories).
- A complete, runnable TypeScript vertical slice demonstrating an Aggregate Root, Value Object, and Application Service with transaction demarcation.
- Common anti-patterns that derail teams attempting DDD, and an actionable production checklist for engineering leads.
The Two Pillars: Strategic vs. Tactical Design
A common mistake among development teams is viewing Domain-Driven Design merely as a set of object-oriented coding patterns—such as creating repositories and private entity setters. In practice, DDD is split into two distinct, interdependent disciplines:
┌─────────────────────────────────────────────────────────────────────────┐
│ STRATEGIC DESIGN │
│ "Doing the Right Things" — Organizational & System Boundaries │
│ │
│ • Ubiquitous Language • Bounded Contexts │
│ • Subdomain Classification • Context Mapping (ACL, OHS/PL, Kernel) │
└────────────────────────────────────┬────────────────────────────────────┘
│ defines boundaries for
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ TACTICAL DESIGN │
│ "Doing Things Right" — Software Modeling & Code Primitives │
│ │
│ • Value Objects • Entities │
│ • Aggregates & Roots • Domain Events │
│ • Repositories • Domain Services vs Application Services │
└─────────────────────────────────────────────────────────────────────────┘
| Dimension | Strategic Design | Tactical Design | | :--- | :--- | :--- | | Primary Focus | System topology, organizational alignment, model boundaries | Code structure, encapsulation, transactional consistency | | Key Questions | What are our distinct business capabilities? Where do models diverge? | What invariants must be atomic? How do we encapsulate state transitions? | | Artifacts | Context Maps, Bounded Context boundaries, Ubiquitous Language dictionaries | Aggregates, Value Objects, Domain Events, Repositories | | Primary Audience | Architects, engineering leads, product managers, domain experts | Software engineers, backend developers | | Risk of Neglect | Distributed monoliths, catastrophic semantic coupling, merge gridlock | Anemic models, scattered validation rules, database concurrency contention |
[!IMPORTANT] Strategic design always precedes tactical design. Implementing tactical aggregates within a single, monolithic, organization-wide data model produces massive object graphs and lock contention. Tactical patterns only succeed when isolated inside a cleanly defined Bounded Context.
Strategic Design: Defining Clear Boundaries
Strategic Design equips teams to model large problem spaces before writing infrastructure code. It identifies what matters most to the business and draws explicit architectural firewalls between different areas of functionality.
1. The Ubiquitous Language
Software fails when developers translate what product owners say into their own idiosyncratic technical jargon. A domain expert speaks of "reconciling a disputed invoice," while the developer's code calls invoiceService.updateStatusFlag(id, 4). When requirements shift, nuances are lost in translation.
The Ubiquitous Language (UL) is a rigorous, unambiguous vocabulary shared between domain experts and software developers, strictly bound to a specific context.
- The language is not a static glossary; it is spoken in team discussions, written in design documents, and reflected verbatim in the source code.
- If the domain expert says, "A customer cancels an order due to a stock discrepancy," the codebase must have a method matching
order.cancel(reason), neverorder.setStatus("CANCELLED")accompanied by arbitrary property adjustments.
Domain Expert Mental Model:
"When an order is submitted, verify available inventory and lock the unit pricing."
│
▼
Ubiquitous Language in Code:
order.submit();
order.lockPricing(currentExchangeRate);
Resolving Polysemy Across Contexts
Human language is inherently polysemic: the same word carries completely different definitions depending on who is speaking. Attempting to create a single "Universal Customer" or "Universal Product" model across an entire enterprise is an anti-pattern that leads to bloated models and cross-team dependencies.
Consider what the word "Account" means across different departments of a modern platform:
| Department / Context | Meaning of "Account" | Key Attributes & Concerns |
| :--- | :--- | :--- |
| Identity & Access | A credential holder with authentication rights | username, passwordHash, mfaEnabled, jwtClaims |
| Billing & Finance | A financial ledger with debits, credits, and invoices | creditBalance, taxExemptStatus, billingAddress, paymentMethod |
| Customer Support | A historical relationship with tickets and interactions | zendeskId, satisfactionScore, escalationTier, assignedRep |
| Marketing | A lead profile categorized by demographic segments | leadSource, funnelStage, campaignAttribution, engagementScore |
In DDD, you do not force these departments to share a monolithic AccountEntity containing 70 properties. Instead, each context models its own specialized representation of "Account" containing only what that context requires.
2. Bounded Contexts
A Bounded Context is the explicit conceptual and physical boundary within which a specific domain model applies and the Ubiquitous Language is strictly consistent.
Inside the boundary, every term has exactly one meaning, and every business rule applies uniformly. Outside the boundary, different models, different definitions, and different schemas prevail.
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ Identity & Access Context │ │ Billing Context │
│ │ │ │
│ • Concept: "User / Credential" │ │ • Concept: "Billing Account" │
│ • Primary Key: `userId` (UUID) │ │ • Primary Key: `accountId` (UUID) │
│ • Invariants: Password complexity, │ │ • Invariants: Double-entry ledger, │
│ session timeout, token validity │ │ tax calculation, balance limits │
└──────────────────┬───────────────────┘ └──────────────────▲───────────────────┘
│ │
└─────────────► Integration ◄────────────────┘
(Domain Events / APIs)
Architectural Boundaries
A Bounded Context is not merely a philosophical boundary; it maps directly to physical software boundaries:
- In a Modular Monolith: A Bounded Context is an isolated top-level module (or package) with its own internal domain types, internal database tables or schema, and explicit public interfaces. Other modules cannot directly import internal domain models or write to its database tables.
- In a Microservices Architecture: A Bounded Context frequently aligns with a single microservice (or a small cluster of tightly related services) backed by a private database.
3. Subdomain Classification
Not all code in an enterprise application is equally valuable. An organization has limited engineering resources and must invest its deepest architectural modeling efforts where they generate competitive differentiation. DDD categorizes business capabilities into three types of subdomains:
┌──────────────────────────────────────────────────────────────────────────┐
│ CORE DOMAIN │
│ Competitive differentiator. High complexity. Build in-house with │
│ custom DDD tactical modeling. (e.g., algorithmic pricing, routing) │
└──────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ supported by │ supported by
┌───────────────────────────┐ ┌─────────────────────────────┐
│ SUPPORTING SUBDOMAIN │ │ GENERIC SUBDOMAIN │
│ Necessary custom logic. │ │ Standard commodity logic. │
│ No competitive edge. │ │ Zero differentiation. │
│ Keep simple; build or │ │ Buy SaaS / use libraries. │
│ outsource. │ │ (e.g., Auth, Billing, │
│ (e.g., warehouse binning│ │ Transactional Email) │
│ optimization) │ │ │
└───────────────────────────┘ └─────────────────────────────┘
| Subdomain Type | Business Impact | Complexity | Strategic Implementation Strategy | | :--- | :--- | :--- | :--- | | Core Domain | High (Differentiator): The primary reason customers choose your software over competitors. | High | Build In-House: Assign your most experienced engineers; apply full tactical DDD (rich aggregates, invariant protection). | | Supporting Subdomain | Medium: Complements the core domain. Necessary for business operations, but not a market differentiator. | Low to Medium | Build Lean / Outsource: Use simpler architectural patterns (transaction scripts, standard CRUD) without over-engineering. | | Generic Subdomain | Low (Commodity): Standard industry capability required by any software business (e.g., Auth0 for auth, Stripe for payments, SendGrid for email). | High to Low | Buy or Integrate: Adopt off-the-shelf software, open-source libraries, or managed SaaS. Do not reinvent. |
4. Context Mapping: Inter-Context Relationships
When multiple Bounded Contexts coexist, they must communicate. A Context Map visualizes the relationships, data flows, and organizational power dynamics between systems.
[ Upstream (U) ] ──────────────────────────────► [ Downstream (D) ]
(Supplier / Provider) (Consumer / Client)
The relationship between two contexts typically falls into one of six established topological patterns:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ CONTEXT MAP PATTERNS │
├──────────────────────┬───────────────────────────────────────────────────────────┤
│ Pattern │ Interaction Dynamics │
├──────────────────────┼───────────────────────────────────────────────────────────┤
│ Shared Kernel │ Context A ◄──────── Shared Code & DB ────────► Context B │
├──────────────────────┼───────────────────────────────────────────────────────────┤
│ Customer-Supplier │ Upstream (Supplier) ───[ negotiated changes ]──► Downstream│
├──────────────────────┼───────────────────────────────────────────────────────────┤
│ Conformist │ Upstream (Dominant) ──[ unnegotiated model ]──► Downstream │
├──────────────────────┼───────────────────────────────────────────────────────────┤
│ Anticorruption Layer │ Upstream ──► [ Translator / Adapter (ACL) ] ──► Downstream│
├──────────────────────┼───────────────────────────────────────────────────────────┤
│ Open Host / Pub Lang │ Upstream ───[ Stable Public API Protocol ]───► Downstream │
├──────────────────────┼───────────────────────────────────────────────────────────┤
│ Separate Ways │ Context A (No integration) Context B │
└──────────────────────┴───────────────────────────────────────────────────────────┘
- Shared Kernel: Two contexts share a subset of the domain model and database tables directly. While tempting for quick code reuse, it creates tight coupling: any change to the shared kernel requires synchronized testing and deployment across both teams.
- Customer-Supplier: An upstream system (Supplier) serves a downstream system (Customer). The downstream team's requirements directly influence the upstream team's delivery priorities.
- Conformist: The downstream context depends on the upstream system, but the upstream team has no motivation or incentive to accommodate the downstream team's requests. The downstream context chooses to conform completely to the upstream domain model.
- Anticorruption Layer (ACL): The downstream system refuses to let foreign concepts contaminate its internal domain model. It introduces an intermediary layer comprising translators, adapters, and facades that convert external payloads into clean, native domain objects.
- Open Host Service / Published Language (OHS/PL): The upstream provider defines a documented, stable protocol (e.g., a versioned REST/JSON API, gRPC schema, or OpenAPI specification) that any downstream client can consume without bespoke integration.
- Separate Ways: Two contexts have zero connection. If a small piece of functionality or data is needed in both places, it is independently implemented or duplicated rather than incurring integration overhead.
Tactical Design: Building Blocks of a Rich Domain Model
Once strategic boundaries are drawn, Tactical Design provides the modeling building blocks inside a single Bounded Context.
┌──────────────────────────────────────────────────┐
│ AGGREGATE ROOT │
│ (Boundary of Transactional Consistency & State) │
│ │
│ ┌───────────────┐ ┌──────────────────┐ │
│ │ Internal │ │ Value Objects │ │
│ │ Entities │ │ (Immutable, Pure)│ │
│ │ (Local ID) │ └──────────────────┘ │
│ └───────────────┘ │
│ │ │
│ ▼ │
│ Emits Domain Events [ OrderPlacedEvent ] │
└──────────────────────────────────────────────────┘
1. Value Objects: Eliminating Primitive Obsession
A Value Object is an immutable domain primitive defined exclusively by its attributes rather than a persistent identity.
- Structural Equality: Two value objects with identical values are considered completely equal (
new Money(10, 'USD').equals(new Money(10, 'USD')) === true). - Complete Immutability: Once instantiated, its internal state can never be modified. Any operation yields a brand-new instance.
- Self-Validation: A value object enforces invariants inside its constructor. It is impossible for an invalid value object to exist in memory.
- Side-Effect-Free Behavior: Methods on value objects perform pure mathematical or operational derivations without modifying external state.
// Eliminating Primitive Obsession: Bad vs. Good
// Bad: Primitive Obsession
function applyDiscount(total: number, currency: string, discount: number): number {
// Prone to negative numbers, currency mismatch, rounding errors
return total - discount;
}
// Good: Rich Value Object
const orderTotal = new Money(150, "USD");
const promoDiscount = new Money(25, "USD");
const discountedTotal = orderTotal.subtract(promoDiscount); // Safe, validated, immutable
2. Entities: Identity Across Lifecycles
An Entity possesses an enduring thread of identity that persists across state mutations and application lifecycles.
- An entity is not defined by its attributes, but by its unique identifier (such as a UUID or domain-specific business key). Two
Userentities with different names but the sameuserIdrepresent the same user. - Encapsulated State: Entities do not expose public setters. Mutation happens exclusively through domain methods that enforce business rules.
| Characteristic | Value Object | Entity |
| :--- | :--- | :--- |
| Identity | None. Defined entirely by its attributes. | Has a persistent unique identity across time. |
| Equality | Structural equality (all properties match). | Identity equality (a.id === b.id). |
| Mutability | Strictly immutable. Always returns new copies. | Mutable state encapsulated behind behavior methods. |
| Lifecycle | Ephemeral. Created and discarded freely. | Long-lived. Persisted, loaded, updated, and archived. |
| Examples | Money, EmailAddress, DateRange, PostalCode | Customer, Order, Invoice, WarehouseBin |
3. Aggregates and Aggregate Roots
An Aggregate is a cluster of associated domain objects (Entities and Value Objects) that are treated as a single transactional consistency unit for data changes. Every Aggregate has an Aggregate Root.
Core Aggregate Rules
- The Gatekeeper Principle: External code can only hold a direct reference to the Aggregate Root. Child entities inside the aggregate boundary cannot be modified directly from the outside.
- Atomic Transactional Boundary: Business invariants within the aggregate must be satisfied synchronously within a single database transaction.
- Reference External Aggregates by Identity Only: An aggregate must never hold a direct object reference to another aggregate root. It must reference foreign aggregates strictly by their unique ID (e.g.,
order.customerId: CustomerId, notorder.customer: Customer). - The Small Aggregates Principle: Large aggregates (such as a
Customeraggregate containing an entire historical array of 10,000Orderobjects) cause severe write-amplification, memory overhead, and optimistic concurrency failures. Aggregates should be kept as compact as possible.
CORRECT (Reference by ID):
┌───────────────────────┐ ┌───────────────────────┐
│ Order │ │ Customer │
│ id: "ord_123" │ │ id: "cust_999" │
│ customerId: "cust_999"─────────────►│ name: "Jane Doe" │
└───────────────────────┘ (ID only) └───────────────────────┘
INCORRECT ANTI-PATTERN (Object Reference):
┌──────────────────────────────────────────────────────────────┐
│ Order │
│ id: "ord_123" │
│ customer: { id: "cust_999", name: "Jane", orders: [...] } │ ◄── Infinite graph!
└──────────────────────────────────────────────────────────────┘
4. Domain Events
A Domain Event records an immutable, historical business occurrence that has already taken place in the domain.
- Past-Tense Naming: Named in the past tense to reflect historical reality (
OrderPlaced,InvoicePaid,InventoryReserved). - Immutability: Once created, domain events cannot be changed.
- Decoupling Side Effects: Rather than having the
Orderaggregate directly call email notification APIs, billing services, and warehouse queue clients, the aggregate emits anOrderPlacedevent. Asynchronous event subscribers handle secondary concerns independently.
5. Domain Services vs. Application Services
Engineers often confuse where business logic belongs when an operation does not naturally fit inside a single entity or aggregate.
| Aspect | Domain Service | Application Service (Use Case) |
| :--- | :--- | :--- |
| Layer | Domain Layer (Core) | Application Layer (Orchestration) |
| Primary Purpose | Pure business calculations or invariant validations involving multiple aggregates. | Orchestrates system workflows, transactions, and infrastructure dependencies. |
| Infrastructure Awareness | Zero. Has no knowledge of databases, HTTP, JSON, or external APIs. | High. Coordinates repositories, transaction managers, and event outbox dispatchers. |
| State | Stateless. Pure domain logic functions. | Stateless workflow handler. |
| Example | CurrencyConversionService.convert(money, exchangeRate) | SubmitOrderUseCase.execute(command) |
6. Repositories
A Repository emulates an in-memory collection of Aggregate Roots.
- Aggregate Roots Only: You create repositories exclusively for Aggregate Roots (e.g.,
OrderRepository). You never create a repository for internal child entities (e.g., anOrderItemRepositoryis an architectural violation because it bypasses the aggregate root's invariant gatekeeper). - Interface in Domain, Implementation in Infrastructure: The repository interface (port) is defined in the domain layer, while the concrete persistence mechanism (adapter using PostgreSQL, MongoDB, or Prisma) is implemented in the infrastructure layer.
Production TypeScript Implementation: A Complete Vertical Slice
To see these concepts working together, consider an e-commerce order management slice. The following code is clean, fully type-safe TypeScript that enforces domain invariants and records domain events.
1. Value Object: Money
// domain/value-objects/money.ts
export class Money {
readonly amount: number;
readonly currency: string;
constructor(amount: number, currency: string) {
if (!Number.isFinite(amount) || amount < 0) {
throw new Error(`Invalid monetary amount: ${amount}. Must be non-negative.`);
}
if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error(`Invalid ISO 4217 currency code: ${currency}. Must be 3 uppercase letters.`);
}
// Round to standard 2 decimal places to prevent floating point inaccuracies
this.amount = Math.round(amount * 100) / 100;
this.currency = currency;
Object.freeze(this);
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.assertSameCurrency(other);
if (this.amount < other.amount) {
throw new Error(`Insufficient funds: cannot subtract ${other.amount} from ${this.amount}.`);
}
return new Money(this.amount - other.amount, this.currency);
}
multiply(quantity: number): Money {
if (!Number.isFinite(quantity) || quantity < 0) {
throw new Error(`Multiplier must be a non-negative finite number: ${quantity}`);
}
return new Money(this.amount * quantity, 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: cannot combine ${this.currency} and ${other.currency}.`);
}
}
}
2. Child Entity and Domain Event
// domain/entities/order-item.ts
import { Money } from "../value-objects/money";
export class OrderItem {
readonly productId: string;
readonly unitPrice: Money;
readonly quantity: number;
constructor(productId: string, unitPrice: Money, quantity: number) {
if (!productId || productId.trim().length === 0) {
throw new Error("OrderItem requires a valid productId.");
}
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error(`Quantity must be a positive integer. Received: ${quantity}`);
}
this.productId = productId.trim();
this.unitPrice = unitPrice;
this.quantity = quantity;
Object.freeze(this);
}
totalPrice(): Money {
return this.unitPrice.multiply(this.quantity);
}
}
// domain/events/order-submitted-event.ts
export interface DomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
readonly aggregateId: string;
readonly eventName: string;
}
export class OrderSubmittedEvent implements DomainEvent {
readonly eventId: string;
readonly occurredOn: Date;
readonly aggregateId: string;
readonly eventName = "OrderSubmitted";
readonly totalAmount: Money;
readonly customerId: string;
constructor(orderId: string, customerId: string, totalAmount: Money) {
this.eventId = crypto.randomUUID();
this.occurredOn = new Date();
this.aggregateId = orderId;
this.customerId = customerId;
this.totalAmount = totalAmount;
Object.freeze(this);
}
}
3. Aggregate Root: Order
// domain/aggregates/order.ts
import { Money } from "../value-objects/money";
import { OrderItem } from "../entities/order-item";
import { DomainEvent, OrderSubmittedEvent } from "../events/order-submitted-event";
export type OrderStatus = "DRAFT" | "SUBMITTED" | "PAID" | "CANCELLED";
export class Order {
readonly id: string;
readonly customerId: string; // Reference external aggregate strictly by ID
private _status: OrderStatus;
private _items: OrderItem[];
private _version: number;
private _domainEvents: DomainEvent[] = [];
constructor(id: string, customerId: string, version = 0) {
if (!id || id.trim().length === 0) throw new Error("Order ID is required.");
if (!customerId || customerId.trim().length === 0) throw new Error("Customer ID is required.");
this.id = id;
this.customerId = customerId;
this._status = "DRAFT";
this._items = [];
this._version = version;
}
get status(): OrderStatus {
return this._status;
}
get version(): number {
return this._version;
}
get items(): readonly OrderItem[] {
return [...this._items];
}
addItem(productId: string, unitPrice: Money, quantity: number): void {
if (this._status !== "DRAFT") {
throw new Error(`Cannot modify order ${this.id} when status is '${this._status}'.`);
}
const existingIndex = this._items.findIndex((item) => item.productId === productId);
if (existingIndex >= 0) {
const existing = this._items[existingIndex];
const updatedQuantity = existing.quantity + quantity;
this._items[existingIndex] = new OrderItem(productId, unitPrice, updatedQuantity);
} else {
this._items.push(new OrderItem(productId, unitPrice, quantity));
}
}
calculateTotal(): Money {
if (this._items.length === 0) {
return new Money(0, "USD");
}
const currency = this._items[0].unitPrice.currency;
return this._items.reduce(
(total, item) => total.add(item.totalPrice()),
new Money(0, currency)
);
}
submit(): void {
if (this._status !== "DRAFT") {
throw new Error(`Order ${this.id} is already in '${this._status}' status and cannot be submitted.`);
}
if (this._items.length === 0) {
throw new Error(`Order ${this.id} must have at least one line item to be submitted.`);
}
this._status = "SUBMITTED";
const total = this.calculateTotal();
// Record domain event internally within the aggregate
this.recordDomainEvent(new OrderSubmittedEvent(this.id, this.customerId, total));
}
cancel(reason: string): void {
if (this._status === "CANCELLED") {
return; // Idempotent operation
}
if (this._status === "PAID") {
throw new Error(`Order ${this.id} is already paid. Cancellation requires initiating a refund.`);
}
if (!reason || reason.trim().length === 0) {
throw new Error("A cancellation reason must be provided.");
}
this._status = "CANCELLED";
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
private recordDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
}
4. Application Service (Use Case) Orchestration
The Application Service orchestrates transactions, repository retrieval, aggregate mutation, and outbox event dispatching. Notice that it contains no domain rules; it merely orchestrates the workflow.
// application/use-cases/submit-order.ts
import { Order } from "../../domain/aggregates/order";
import { DomainEvent } from "../../domain/events/order-submitted-event";
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export interface OutboxRepository {
appendEvents(events: DomainEvent[]): Promise<void>;
}
export interface UnitOfWork {
execute<T>(work: () => Promise<T>): Promise<T>;
}
export class SubmitOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly outboxRepository: OutboxRepository,
private readonly unitOfWork: UnitOfWork
) {}
async execute(orderId: string): Promise<void> {
await this.unitOfWork.execute(async () => {
// 1. Load aggregate root from repository
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new Error(`Order with ID ${orderId} not found.`);
}
// 2. Invoke business method on the aggregate (enforces domain invariants)
order.submit();
// 3. Extract domain events recorded by the aggregate
const domainEvents = order.pullDomainEvents();
// 4. Atomically persist the aggregate and append events to the transactional outbox
await this.orderRepository.save(order);
await this.outboxRepository.appendEvents(domainEvents);
});
}
}
Critical Anti-Patterns & Engineering Traps
When teams adopt Domain-Driven Design without careful calibration, they frequently fall into five predictable architectural traps:
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMMON DDD TRAPS │
├─────────────────────────┬───────────────────────────────────────────────────┤
│ The Anemic Domain Model │ Entities are passive data bags; logic in services │
├─────────────────────────┼───────────────────────────────────────────────────┤
│ The God Aggregate │ Deep object hierarchies causing DB lock contention│
├─────────────────────────┼───────────────────────────────────────────────────┤
│ Premature Tactical DDD │ Applying heavy tactical patterns to simple CRUD │
├─────────────────────────┼───────────────────────────────────────────────────┤
│ Cross-Context DB Joins │ Services bypassing APIs to query neighbor tables │
├─────────────────────────┼───────────────────────────────────────────────────┤
│ ORM Coupling │ Modeling business entities around database tables │
└─────────────────────────┴───────────────────────────────────────────────────┘
1. The Anemic Domain Model
Coined by Martin Fowler, this anti-pattern occurs when developers create entities that are nothing more than collections of getters and setters with zero behavior.
All validation and business logic are extracted into procedural service classes. The result is that state and logic are decoupled:
- Invariants cannot be guaranteed because any service in the application can directly mutate entity fields into an invalid state.
- Business rules are duplicated across dozens of different controller and service methods.
Remedy: Make internal entity properties private or read-only. Remove generic setters. Force all state transitions to occur through expressive domain methods (order.cancel(reason), order.addItem(...)).
2. The God Aggregate
A common rookie mistake is modeling real-world object hierarchies literally: Customer owns a list of Orders, which owns a list of Invoices, which owns Shipments.
- Loading a
Customerloads thousands of relational rows into memory. - Under high concurrency, two independent users updating different orders for the same customer collide on database row locks or trigger optimistic locking exceptions.
Remedy: Follow the Small Aggregates Principle. Break large graphs into distinct aggregates. Reference external aggregates strictly by identity (customerId: string) rather than embedding object instances.
3. Premature Tactical DDD on Simple CRUD Workloads
Domain-Driven Design is designed for domains with high essential complexity: complicated business rules, intricate workflows, and strict state invariants.
- Applying aggregates, repositories, and value objects to an administrative panel that performs simple create-read-update-delete operations on flat database rows introduces massive boilerplate with zero return on investment.
Remedy: Use the appropriate tool for the job. Use simple Active Record or direct ORM queries for generic CRUD subdomains; reserve rich tactical DDD for the Core Domain where business rules justify the modeling effort.
4. Direct Database Coupling Across Bounded Contexts
When teams split an application into microservices or modular monolith packages, they often leave database connections shared across boundaries. A developer in the BillingContext writes an SQL JOIN directly against tables owned by the InventoryContext.
- When the inventory team alters its internal relational schema to optimize warehouse sorting, the billing queries crash silently.
Remedy: Enforce strict schema boundaries. Contexts must communicate exclusively via asynchronous Domain Events, public REST/gRPC APIs, or an Anticorruption Layer.
5. Conflating ORM Entities with Domain Entities
Developers frequently use their ORM entities (e.g., Prisma models, TypeORM classes, Hibernate entities) as their core domain model.
- ORM entities are constrained by the requirements of the database engine (surrogate keys, foreign key declarations, lazy-loading proxies, parameterless constructors).
- Domain entities must be constrained exclusively by business rules and domain invariants.
Remedy: Decouple the two. The domain layer should use pure native language objects with zero ORM decorators. The infrastructure repository implementation is responsible for mapping between internal domain entities and database persistence records.
Production Engineering & Concurrency Considerations
Running DDD in production requires solving concurrency, consistency, and query optimization challenges:
1. Optimistic Concurrency Control (OCC)
In high-throughput distributed systems, multiple requests may attempt to update the same aggregate simultaneously (e.g., two users modifying items in a shared shopping cart).
- Rather than locking database rows with pessimistic locking (
SELECT FOR UPDATE), aggregates maintain an incrementalversion: number. - When persisting, the repository executes:
UPDATE orders SET status = $1, version = version + 1 WHERE id = $2 AND version = $3; - If the affected row count is 0, another process modified the aggregate in the interim. The repository throws a
ConcurrencyConflictException, prompting the application service to retry or alert the caller.
2. Dual-Write Problem and the Transactional Outbox Pattern
When an application modifies an aggregate and publishes an event to an external message broker (such as Kafka or RabbitMQ), a dual-write failure can occur:
- If the database commit succeeds but the message broker is temporarily unreachable, downstream consumers never receive the event.
- If the event is sent first and the database transaction rolls back, the system emits a phantom event describing an action that never happened.
The Solution: Write both the aggregate state and the domain event into the local database within the same atomic ACID transaction. A dedicated background relay process reads unpublished events from the outbox table and dispatches them to the message broker with at-least-once delivery guarantees.
3. Pairing DDD with CQRS (Command Query Responsibility Segregation)
Aggregates are write-optimized models designed to protect invariants during state mutations. They are inefficient for complex analytics or multi-table reporting queries.
- Write Side (Commands): Executes through Aggregates and Repositories, guaranteeing business invariants and atomicity.
- Read Side (Queries): Bypasses domain aggregates entirely. Queries read from denormalized database views or read-optimized projections (e.g., Elasticsearch, PostgreSQL materialized views) to deliver high-performance search and display data.
Practical Production Review Checklist
Before deploying a domain-driven architecture to production, use this checklist during design and code reviews:
┌─────────────────────────────────────────────────────────────────────────────┐
│ DDD PRODUCTION READINESS CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ STRATEGIC INTEGRITY │
│ [ ] Ubiquitous Language is reflected directly in class and method names. │
│ [ ] Bounded Context boundaries are enforced (no cross-context table joins). │
│ [ ] Context mapping relationships (ACL, OHS/PL, Conformist) are documented. │
│ [ ] Core vs. Supporting vs. Generic subdomains are identified. │
├─────────────────────────────────────────────────────────────────────────────┤
│ TACTICAL ROBUSTNESS │
│ [ ] Value Objects are completely immutable and self-validating. │
│ [ ] Entities protect internal state (no public setters or leaking arrays). │
│ [ ] Aggregate roots are the sole entry point for modifying child entities. │
│ [ ] External aggregates are referenced strictly by ID, not object graph. │
│ [ ] Repositories exist exclusively for Aggregate Roots. │
├─────────────────────────────────────────────────────────────────────────────┤
│ OPERATIONAL RESILIENCE │
│ [ ] Optimistic Concurrency Control (version checking) is implemented. │
│ [ ] Domain events are recorded via the Transactional Outbox pattern. │
│ [ ] Read queries do not traverse domain aggregates for display views (CQRS).│
│ [ ] Unit tests verify domain invariants in memory without database I/O. │
└─────────────────────────────────────────────────────────────────────────────┘
Conclusion & Key Takeaways
Domain-Driven Design is not an all-or-nothing proposition, nor is it a dogmatic set of rules for every CRUD endpoint. It is an engineering discipline that aligns software architecture with business reality:
- Strategic Before Tactical: Clean boundaries, linguistic clarity, and Bounded Contexts matter far more than whether your entities use private fields.
- Respect Linguistic Boundaries: Eliminate universal enterprise models. Let "Account" or "Product" mean different things in different contexts.
- Keep Aggregates Small: Design aggregates around true transactional invariants. Reference other aggregates by identifier to prevent performance bottlenecks and database lock contention.
- Isolate Complexity with ACLs: Use Anticorruption Layers when integrating legacy systems or third-party APIs to keep your core domain model pristine.
- Write Expressive Code: Make business rules explicit in code so that any engineer reading the repository can immediately understand what the domain guarantees.


