Domain-Driven Design in Practice: Bounded Contexts, Aggregates, and Context Mapping for Production TypeScript Applications
A practical guide to applying DDD in real startup codebases. Covers strategic patterns (bounded contexts, context mapping, anti-corruption layers) and tactical patterns (aggregates, value objects, domain events) with TypeScript implementations, including when DDD is worth the investment and how it fits into modular monolith and event-driven architectures.
Most engineers encounter DDD through its vocabulary first: bounded contexts, aggregates, ubiquitous language. The vocabulary is memorable. The practical guidance on how to actually apply it to a TypeScript codebase that needs to ship features next week is considerably harder to find.
This guide skips the philosophy and focuses on the decisions. When do you draw a context boundary? What goes inside an aggregate, and what does not? How do domain events interact with a message broker? Where does an anti-corruption layer earn its weight?
The patterns here are drawn from applying DDD to real SaaS codebases, not from modeling a hypothetical bookshop.
When DDD Is Worth It
DDD has a cost. It adds indirection, forces explicit modeling decisions, and requires the team to maintain a shared vocabulary that diverges from both the database schema and the API contract. That cost is only justified when your domain is genuinely complex.
The threshold is roughly: if the business logic fits in a handful of service functions that mostly CRUD records, DDD is premature abstraction. If you have invariants that span multiple entities, business rules that change frequently and independently across different parts of the system, or multiple teams whose mental models of “an order” or “a user” conflict with each other, DDD pays for itself.
The signal that DDD is underused in an existing codebase is usually a god service: a 2,000-line OrderService that knows about payments, shipping, inventory, and customer loyalty simultaneously, where changing the discount logic requires tracing through six methods to understand what invariants it might violate.
Strategic Patterns: Carving the Problem Space
Strategic DDD is about decomposition at the problem level, before you write a single class.
Bounded Contexts
A bounded context is a boundary within which a particular domain model applies. The word “customer” means something specific inside that boundary, and possibly something different outside it. In an e-commerce system, “customer” in the order context means an entity with a shipping address and payment method. In the support context it means a person with a ticket history. In the analytics context it means an anonymized event source.
Trying to build one universal Customer model that satisfies all three contexts produces a bloated entity that is wrong for each use case in a different way.
Bounded contexts map well to modules in a modular monolith. Each module owns its own types, its own database tables, and exposes a public API that other modules call. The modular monolith approach formalizes this with directory structure and enforced import boundaries, and DDD provides the conceptual foundation for deciding where those boundaries belong.
// Each context defines its own projection of shared concepts.
// These are NOT the same type, even though both are "customers".
// billing/types.ts
export interface BillingCustomer {
id: string;
stripeCustomerId: string;
defaultPaymentMethodId: string | null;
currency: string;
}
// support/types.ts
export interface SupportCustomer {
id: string;
email: string;
displayName: string;
tier: "free" | "pro" | "enterprise";
openTicketCount: number;
}
Neither type is “wrong.” Each is right for its context. The shared identifier (id) is what allows you to correlate across contexts when needed.
Context Mapping
Once you have bounded contexts, you need to define how they relate to each other. Context mapping is the practice of making those relationships explicit. The most common patterns:
Shared Kernel: Two contexts share a small, stable subset of the model. Changes to the kernel require coordination between both teams. Use this only for genuinely stable concepts like money amounts or identifiers.
Customer-Supplier: One context produces data that another consumes. The supplier defines the contract; the consumer adapts. This is the default relationship for internal services.
Anti-Corruption Layer (ACL): One context needs to integrate with an external system (or a legacy internal system) whose model is messy or at odds with your domain. The ACL translates between the external model and your clean domain model. You absorb the translation cost at the boundary so it does not leak into your domain logic.
// External payment provider uses its own terminology and shapes.
// The ACL translates so the billing context never sees Stripe types.
interface StripePaymentIntent {
id: string;
amount: number; // cents
currency: string;
status: "requires_payment_method" | "requires_confirmation" | "succeeded" | "canceled";
metadata: Record<string, string>;
}
// billing/acl/stripe-acl.ts
import type { StripePaymentIntent } from "../external/stripe-types";
import type { PaymentAuthorization } from "../domain/payment-authorization";
export function toPaymentAuthorization(intent: StripePaymentIntent): PaymentAuthorization {
return {
id: intent.id,
amountCents: intent.amount,
currency: intent.currency,
status: mapIntentStatus(intent.status),
orderId: intent.metadata["orderId"] ?? null,
};
}
function mapIntentStatus(
status: StripePaymentIntent["status"]
): PaymentAuthorization["status"] {
switch (status) {
case "succeeded":
return "authorized";
case "canceled":
return "voided";
default:
return "pending";
}
}
The billing domain code never imports from stripe-types. If Stripe renames a field, you fix it in one file.
Tactical Patterns: Modeling Inside a Context
Once you have context boundaries, tactical DDD gives you patterns for modeling the domain logic inside each context.
Entities and Value Objects
An entity has identity that persists across time. A Customer with id usr_abc123 is the same customer whether their email changes or not. Identity is the thing that makes two instances “the same object.”
A value object has no identity. It is defined entirely by its attributes. Two Money instances with the same amount and currency are interchangeable. You do not care which one you have. Value objects should be immutable.
// Entity: identity matters, mutable over time
class Customer {
constructor(
public readonly id: CustomerId,
private email: Email,
private tier: CustomerTier
) {}
upgradeToEnterprise(): void {
this.tier = CustomerTier.Enterprise;
}
getEmail(): Email {
return this.email;
}
}
// Value object: no identity, immutable, equality by value
class Money {
private constructor(
public readonly amountCents: number,
public readonly currency: string
) {}
static of(amountCents: number, currency: string): Money {
if (amountCents < 0) throw new Error("Money cannot be negative");
if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code");
return new Money(amountCents, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) {
throw new Error("Cannot add money of different currencies");
}
return Money.of(this.amountCents + other.amountCents, this.currency);
}
equals(other: Money): boolean {
return this.amountCents === other.amountCents && this.currency === other.currency;
}
}
The key discipline with value objects: put your validation in the constructor (or a static factory). A Money instance that reaches domain logic is already guaranteed to be a valid amount in a valid currency. You do not scatter if (amount < 0) guards across the codebase.
Aggregates and Aggregate Roots
An aggregate is a cluster of entities and value objects that form a consistency boundary. The aggregate root is the single entity through which all access to the aggregate flows. You load the whole aggregate, apply operations through the root, and persist the whole thing. You never reach into the aggregate from outside and modify a child entity directly.
The practical benefit: all invariants that span multiple entities inside the aggregate are enforced in one place, by the root.
type OrderStatus = "draft" | "confirmed" | "shipped" | "cancelled";
interface LineItem {
productId: string;
quantity: number;
unitPriceCents: number;
}
class Order {
private lineItems: LineItem[] = [];
private status: OrderStatus = "draft";
constructor(
public readonly id: OrderId,
public readonly customerId: CustomerId,
private readonly events: DomainEvent[] = []
) {}
addItem(productId: string, quantity: number, unitPriceCents: number): void {
if (this.status !== "draft") {
throw new Error("Cannot modify a confirmed order");
}
if (quantity <= 0) throw new Error("Quantity must be positive");
const existing = this.lineItems.find((li) => li.productId === productId);
if (existing) {
existing.quantity += quantity;
} else {
this.lineItems.push({ productId, quantity, unitPriceCents });
}
}
confirm(): void {
if (this.status !== "draft") throw new Error("Order already confirmed");
if (this.lineItems.length === 0) throw new Error("Cannot confirm empty order");
this.status = "confirmed";
this.events.push(
new OrderConfirmed({
orderId: this.id.value,
customerId: this.customerId.value,
totalCents: this.totalCents(),
confirmedAt: new Date().toISOString(),
})
);
}
totalCents(): number {
return this.lineItems.reduce(
(sum, li) => sum + li.unitPriceCents * li.quantity,
0
);
}
pullEvents(): DomainEvent[] {
const pending = [...this.events];
this.events.length = 0;
return pending;
}
}
The Order root enforces two key invariants: you cannot modify an order that is not in draft state, and you cannot confirm an empty order. These rules are in one class, not scattered across a service layer.
Aggregate Boundary Design
Getting aggregate boundaries wrong is the most common DDD mistake. Aggregates that are too large create contention: multiple concurrent operations need to lock the same aggregate root even when they affect logically separate parts of it. Aggregates that are too small fail to enforce invariants that span the pieces you split apart.
The practical heuristic: an aggregate should be the smallest unit of consistency. Ask “what invariant requires these entities to change together in the same transaction?” The answer defines the boundary.
In an e-commerce context, Order and its LineItems belong in one aggregate because the “order total” invariant spans both. But Order and Product do not belong in one aggregate. When you confirm an order, you do not need to lock the product record. Inventory reservation is a separate consistency concern that belongs either in its own aggregate or as an eventual consistency step.
// Wrong: Order aggregate contains full Product entity
// This creates contention and couples two different rates of change
class Order {
private lineItems: Array<{ product: Product; quantity: number }> = [];
}
// Right: Order stores only the reference and snapshot data it needs
class Order {
private lineItems: Array<{
productId: ProductId; // reference
productName: string; // snapshot for display
unitPriceCents: number; // snapshot at time of order
quantity: number;
}> = [];
}
Snapshotting the price at order time rather than referencing the live Product entity is both the correct DDD approach and the correct business rule: the price an order was placed at should not change if the product’s price changes later.
Domain Events
Domain events capture business occurrences that other parts of the system may care about. OrderConfirmed, PaymentFailed, SubscriptionExpired. They are facts, named in the past tense, describing something that happened inside the aggregate.
abstract class DomainEvent {
abstract readonly type: string;
readonly occurredAt: string = new Date().toISOString();
}
class OrderConfirmed extends DomainEvent {
readonly type = "OrderConfirmed" as const;
constructor(
public readonly payload: {
orderId: string;
customerId: string;
totalCents: number;
confirmedAt: string;
}
) {
super();
}
}
class PaymentFailed extends DomainEvent {
readonly type = "PaymentFailed" as const;
constructor(
public readonly payload: {
orderId: string;
reason: string;
failedAt: string;
}
) {
super();
}
}
The aggregate collects events during a business operation. The repository publishes them after a successful save. This pattern keeps domain logic free of any coupling to a message broker, and it ensures events are only published when the state change is actually persisted.
class OrderRepository {
constructor(
private readonly db: Database,
private readonly eventBus: EventBus
) {}
async save(order: Order): Promise<void> {
await this.db.transaction(async (tx) => {
await tx.upsert("orders", order.toSnapshot());
// Commit the state change first
});
// Publish events after successful commit
const events = order.pullEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
}
}
For stronger guarantees, combine this with the outbox pattern: write events to an outbox table inside the same transaction as the aggregate state, then have a separate process read and publish them. This eliminates the gap between a successful DB write and a failed event publish.
Repositories
A repository abstracts the persistence mechanism for an aggregate. From the domain’s perspective, a repository looks like an in-memory collection: you load an aggregate by ID, you save changes. No SQL, no ORM concepts.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.query(
"SELECT * FROM orders WHERE id = $1",
[id.value]
);
if (!row) return null;
const lineItems = await this.db.query(
"SELECT * FROM order_line_items WHERE order_id = $1 ORDER BY created_at",
[id.value]
);
return Order.fromSnapshot({ ...row, lineItems });
}
async save(order: Order): Promise<void> {
// Upsert order and line items, then publish events
}
}
The interface lives in the domain layer. The Postgres implementation lives in the infrastructure layer. Your domain logic tests use an in-memory implementation. This is not academic purity: it meaningfully speeds up test suites that would otherwise hit the database for every aggregate operation.
Domain Events and Event-Driven Architecture
Domain events inside an aggregate and integration events on a message broker are different things with different concerns. Domain events are internal, strongly typed, and emitted synchronously as part of a business operation. Integration events are published to a broker, consumed asynchronously by other services or contexts, and need to be designed for versioning and schema stability.
The translation layer between them is where you make deliberate decisions about what to expose to the outside world.
// Domain event: internal, rich with domain types
class OrderConfirmed extends DomainEvent {
readonly type = "OrderConfirmed" as const;
// ... rich payload with domain types
}
// Integration event: external, serializable, versioned
interface OrderConfirmedIntegrationEvent {
eventId: string;
eventType: "order.confirmed";
schemaVersion: 1;
occurredAt: string;
data: {
orderId: string;
customerId: string;
totalCents: number;
currency: string;
};
}
function toIntegrationEvent(
event: OrderConfirmed
): OrderConfirmedIntegrationEvent {
return {
eventId: crypto.randomUUID(),
eventType: "order.confirmed",
schemaVersion: 1,
occurredAt: event.occurredAt,
data: {
orderId: event.payload.orderId,
customerId: event.payload.customerId,
totalCents: event.payload.totalCents,
currency: "usd",
},
};
}
This separation is discussed further in event-driven architecture patterns: the internal domain event model does not need to match the external contract, and conflating them makes both harder to evolve.
DDD and the Modular Monolith
DDD’s bounded contexts map directly onto modules in a modular monolith. Each bounded context is a module directory with a public index.ts barrel, its own database tables, and enforced import boundaries. Other modules call your module’s public API. They do not reach into your internal types.
The practical sequence: start with DDD’s strategic analysis to identify context boundaries, then implement those boundaries as modules. Extract to separate services only when you have a measurable scaling reason to do so, not as a speculative decomposition.
src/
modules/
orders/
domain/
order.ts # Order aggregate root
order-id.ts # OrderId value object
money.ts # Money value object
events.ts # Domain events
application/
confirm-order.ts # Use case: confirms an order
get-order.ts # Use case: query
infrastructure/
postgres-order-repository.ts
stripe-acl.ts # Anti-corruption layer for Stripe
index.ts # Public API of this module
billing/
...
inventory/
...
The index.ts barrel exports only what other modules are allowed to call. Everything under domain/ and infrastructure/ is the module’s private implementation. An ESLint no-restricted-imports rule or a tool like eslint-plugin-boundaries can enforce this at the CI level.
Tradeoffs
| Concern | DDD approach | Cost |
|---|---|---|
| Invariant enforcement | Aggregate root as single consistency point | Aggregate must be fully loaded; partial loads are not supported |
| Persistence | Repository per aggregate | More boilerplate than an ORM with relations |
| Cross-context queries | Read models built from integration events | Eventual consistency; read models can lag |
| Refactoring domains | Explicit ubiquitous language per context | Requires ongoing team alignment; vocabulary drift is a real failure mode |
| External integrations | Anti-corruption layer at every boundary | Translation code to maintain; adds indirection |
| Event publishing | Domain events collected, published post-commit | Requires outbox or 2PC for guaranteed delivery |
Production Considerations
Aggregate size and lock contention: A large aggregate that is frequently written to under concurrent load will serialize operations. If you find yourself adding a queue in front of an aggregate to prevent contention, that is a signal the aggregate is too coarse. Split it.
Eventual consistency across contexts: When the inventory context needs to know about confirmed orders, it subscribes to order.confirmed integration events. There will be a window where the order is confirmed but the inventory has not yet reserved stock. Design your UI and your downstream logic to handle this window. It is not a bug; it is the explicit tradeoff for loose coupling.
Schema evolution for domain events: Stored domain events (in an event-sourced system) or integration events on a broker need versioning from day one. Adding an optional field is safe. Renaming a field or changing a type requires a migration strategy. See the event sourcing article for a practical approach to schema migration with upcasters.
Testing strategy: The aggregate root is a plain TypeScript class with no infrastructure dependencies. Unit tests load it directly, call methods, and assert on the resulting state or emitted events. Integration tests go through the repository. This separation makes domain logic tests fast (no database) and infrastructure tests explicit (testing the actual SQL).
// Fast unit test: no database, no mocks needed
describe("Order", () => {
it("cannot be confirmed when empty", () => {
const order = new Order(OrderId.generate(), CustomerId.of("cust_1"));
expect(() => order.confirm()).toThrow("Cannot confirm empty order");
});
it("emits OrderConfirmed on successful confirm", () => {
const order = new Order(OrderId.generate(), CustomerId.of("cust_1"));
order.addItem("prod_abc", 2, 4999);
order.confirm();
const events = order.pullEvents();
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(OrderConfirmed);
expect((events[0] as OrderConfirmed).payload.totalCents).toBe(9998);
});
});
Ubiquitous language maintenance: The code is where the language lives. If the product team says “subscription” and the code says “billing plan,” you have language drift. Every divergence is a translation cost every time an engineer moves between a conversation and the codebase. Keep the names aligned, and update both when either changes.
When to Pull Back
DDD is not the right tool for every part of the codebase. A context that is mostly read-heavy reporting, a context that maps 1:1 to a third-party API, or a context that has not changed in two years are all poor candidates for full DDD treatment. Apply tactical patterns where the domain is actively complex and changing. Use simpler CRUD patterns where the problem is simple and stable. The goal is honest modeling, not pattern completeness.
The service decomposition playbook covers what happens when a bounded context grows large enough that it needs to become its own deployable service. The DDD analysis you did upfront is what makes that extraction tractable: you already have clean boundaries, explicit contracts, and a defined event vocabulary.
What Good DDD Looks Like in Practice
After six months, a codebase with well-applied DDD has a few recognizable properties: new engineers can read the domain code and understand the business rules without reading the database schema; invariant violations are caught at compile time or at the aggregate boundary before they touch the database; the word “customer” means exactly one thing inside each module and the team has stopped arguing about what it means; and adding a new business rule to an aggregate requires editing one file, not tracing through a chain of service calls.
That is the outcome worth designing for.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.