DDD: Bounded Contexts That Actually Work
Why DDD Matters
Domain-Driven Design is not about code patterns. It’s about aligning software structure with business reality.
Most systems fail because the domain model doesn’t match the domain. The code says “User” when the business talks about “Customer,” “Subscriber,” and “Admin” as distinct concepts. The code has a monolithic “Order” entity when the business distinguishes between “Quote,” “PurchaseOrder,” and “Invoice.” The mismatch compounds over time until the codebase becomes unmaintainable.
DDD provides a framework for keeping code and business aligned. The core tool is bounded contexts — explicit boundaries that define where a term means what.
The Most Important Concept: Bounded Contexts
The same word means different things in different contexts. This is not ambiguity — it’s reality.
Consider “Part” in a manufacturing system:
-
In PLM (Product Lifecycle Management): A “Part” is a design artifact with a lifecycle (Draft → Approved → Released). It has revisions, approval workflows, and CAD attachments. The language is: design, revision, approval, release, obsolescence.
-
In MES (Manufacturing Execution System): A “Part” is a production item. It has routings, work centers, cycle times, and yield rates. The language is: schedule, execute, track, complete, scrap.
-
In Inventory: A “Part” is an item you count. It has stock quantities, locations, lot numbers, and expiration dates. The language is: allocate, reserve, consume, replenish.
These are not the same concept. They share a name, but the attributes, behaviors, and rules differ. Trying to unify them into a single “Part” entity creates a monolith that satisfies no one.
Bounded contexts make this explicit. PLM has a Part entity. MES has a Part entity. Inventory has a Part entity. They’re distinct models with distinct rules, and the boundaries between them are well-defined.
When a Part is released in PLM, an event is published: PartReleased. MES subscribes to this event and creates its own Part record for production planning. The two models remain independent.
Context Mapping Patterns
Once you have bounded contexts, you need to define how they interact. This is context mapping.
1. Customer-Supplier
One context (upstream) publishes data or events. Another context (downstream) subscribes.
Example: PLM is upstream. MES is downstream. When PLM releases a Part, it publishes a PartReleased event. MES subscribes, extracts the data it needs (part number, BOM), and creates its own production record.
The key: the upstream context defines the contract. The downstream context adapts. PLM doesn’t know or care that MES exists. MES depends on PLM, not vice versa.
2. Open Host Service
One context exposes a well-defined API. Other contexts call it as needed.
Example: Inventory exposes a REST API for checking stock levels. MES calls this API when scheduling production. MES doesn’t know how Inventory stores data (SQL? DynamoDB?). It just calls /api/inventory/check-stock and gets a response.
The key: the API is a published contract. Changes to the API require versioning and migration. The host context is responsible for backward compatibility.
3. Anti-Corruption Layer
When integrating with an external system (legacy ERP, third-party API), you don’t want its model to leak into your domain. You build a translation layer that converts external data into your internal model.
Example: You’re integrating with a legacy ERP that has a convoluted “Item Master” schema. Instead of exposing this to your domain, you build an adapter:
class ErpPartAdapter {
toPart(erpItem: ErpItemMaster): Part {
// Translate ERP's 47 fields into your clean Part model
return new Part(
new PartId(erpItem.ITEM_NO),
new PartNumber(erpItem.PART_NUM),
erpItem.STATUS === 'A' ? PartStatus.Active : PartStatus.Inactive
);
}
}
The Anti-Corruption Layer protects your domain from external complexity. The rest of your system works with Part, not ErpItemMaster.
4. Shared Kernel
Two contexts share a subset of the domain model. This is rare and requires tight coordination between teams.
Example: PLM and Engineering both use the same PartNumber value object. They share the validation logic (format, uniqueness rules). Changes to PartNumber require agreement from both teams.
Warning: Shared Kernels create coupling. Use sparingly. Most contexts should be independent.
5. Conformist
The downstream context adopts the upstream model without translation. This is acceptable when the upstream model is already a good fit.
Example: A reporting context subscribes to MES events and stores them as-is for analytics. No translation needed — the MES model works fine for reporting.
Trade-off: Simplicity (no translation layer) vs. coupling (changes in MES break the reporting context).
Ubiquitous Language
A bounded context has a ubiquitous language — a shared vocabulary used by both developers and domain experts.
If the business says “released,” the code says released. Not activated, not published, not approved. The term in the code matches the term in conversation.
This is harder than it sounds. Developers love abstraction. They generalize “Invoice” and “Quote” into “Document.” They merge “Customer” and “Supplier” into “Party.” This destroys clarity.
The language in the code should mirror the language in the business. If the language is wrong, the model is wrong.
Tactical Patterns: The Building Blocks
Once you have bounded contexts and ubiquitous language, you need tactical patterns to structure the code.
Entities (Identity-Based)
An Entity has a unique identity that persists over time. Two entities with the same attributes but different IDs are distinct.
Example: Two Part entities with the same part number but different revisions are distinct. Identity = PartId.
class Part {
constructor(
public readonly id: PartId, // Identity
public readonly number: PartNumber,
public revision: Revision,
public status: PartStatus
) {}
equals(other: Part): boolean {
return this.id.equals(other.id); // Identity-based equality
}
}
Value Objects (Attribute-Based, Immutable)
A Value Object has no identity. Two value objects with the same attributes are identical.
Example: PartNumber is a value object. If two PartNumber instances have the value “PN-12345,” they’re the same.
class PartNumber {
constructor(public readonly value: string) {
if (!value.match(/^PN-\d{5}$/)) {
throw new Error('Invalid part number format');
}
}
equals(other: PartNumber): boolean {
return this.value === other.value; // Attribute-based equality
}
}
Value objects are immutable. If you need a different part number, you create a new instance.
Aggregates (Consistency Boundaries)
An Aggregate is a cluster of entities and value objects treated as a single unit for consistency. The aggregate root is the only entity externally accessible.
Example: A BOM (Bill of Materials) is an aggregate. It contains a Part (root) and a list of BOMLine entities (children).
class BOM {
constructor(
public readonly part: Part, // Aggregate root
private lines: BOMLine[]
) {}
addLine(childPart: Part, quantity: number): void {
// Business rule: can't add duplicate parts
if (this.lines.some(line => line.part.equals(childPart))) {
throw new Error('Part already in BOM');
}
this.lines.push(new BOMLine(childPart, quantity));
}
getLines(): readonly BOMLine[] {
return this.lines; // Return immutable view
}
}
Rules:
- External code interacts with the
BOM, not theBOMLineentities directly. - All modifications go through the aggregate root.
- The aggregate enforces invariants (e.g., no duplicate parts).
Domain Events (Facts That Happened)
A Domain Event represents something that happened in the domain. It’s immutable and past-tense.
Example: PartReleased, BomUpdated, EcoApproved.
class PartReleased {
constructor(
public readonly partId: PartId,
public readonly partNumber: PartNumber,
public readonly releasedAt: Date,
public readonly releasedBy: UserId
) {}
}
Domain events enable loose coupling between bounded contexts. PLM publishes PartReleased. MES subscribes and reacts. PLM doesn’t know MES exists.
When DDD Is Overkill
DDD has overhead. It requires discipline, adds complexity, and slows initial development. It’s not always worth it.
Skip DDD when:
1. Simple CRUD. If your system is just saving and retrieving data with minimal business logic, DDD is over-engineering. A traditional layered architecture (or Active Record) is faster and simpler.
2. No complex domain logic. If there are no intricate business rules, state transitions, or invariants, entities and aggregates add no value. Just use DTOs and database models.
3. Small, short-lived projects. If you’re building a prototype or a tool that will be replaced in six months, don’t architect for longevity. Ship fast, learn, move on.
When DDD Is Essential
DDD pays off in three scenarios:
1. Multiple teams. When different teams own different parts of the system, bounded contexts prevent coordination bottlenecks. Each team owns a context and defines its own model. Integration happens at well-defined boundaries.
2. Complex business rules. When the domain has intricate constraints, workflows, and invariants, isolating that logic in entities and aggregates makes it testable and maintainable.
3. Long-lived systems. When a codebase will be maintained for 5+ years, the upfront cost of DDD pays dividends. The domain model evolves with the business, but the structure remains stable.
Real Example: Manufacturing Domain
Let’s see how DDD plays out in a manufacturing PLM system.
Bounded Context: PLM
Entities:
Part— lifecycle (Draft → Approved → Released → Obsolete), identity =PartIdECO(Engineering Change Order) — change workflow (Proposed → Approved → Implemented), identity =EcoId
Value Objects:
PartNumber— format validation, immutableRevision— “A”, “B”, “C” (semantic versioning for parts)
Aggregates:
BOM— aggregate root isPart, containsBOMLinechildrenECO— aggregate root isECO, containsEcoLineItemchildren (parts affected by change)
Domain Events:
PartCreated— when a new part is designedPartReleased— when a part is approved for productionEcoApproved— when a change order is approved
Context Mapping
PLM → MES (Customer-Supplier):
- PLM publishes
PartReleasedevent. - MES subscribes, creates production record.
- MES has its own
Partentity (different attributes, different rules).
MES → Inventory (Open Host Service):
- MES calls Inventory API to check stock before scheduling.
- Inventory exposes
/api/inventory/check-stock. - MES doesn’t care how Inventory stores data.
PLM → Legacy ERP (Anti-Corruption Layer):
- PLM integrates with legacy ERP for cost data.
ErpCostAdaptertranslates ERP’s complex schema into PLM’sPartCostvalue object.- ERP complexity stays at the boundary, doesn’t leak into PLM domain.
Conclusion
DDD is not about patterns for their own sake. It’s about aligning code with business reality.
Bounded contexts prevent model collapse. A “Part” in PLM is not the same as a “Part” in MES. Trying to unify them creates a monolith that satisfies no one.
Ubiquitous language ensures clarity. The code speaks the same language as the business. If the business says “released,” the code says released.
Tactical patterns — entities, value objects, aggregates, domain events — provide structure. They make complex domains testable, maintainable, and evolvable.
But DDD has overhead. Use it when the benefits justify the cost: complex domains, multiple teams, long-lived systems. For simple CRUD, skip it.
The domain should be discoverable by reading the code. If you can’t understand the business by reading the domain layer, the model is wrong.
That’s DDD.