Domain-Driven Design (DDD): From Strategic Patterns to Tactical Implementation
Software fails when developers build the wrong abstraction. Domain-Driven Design aligns your code with the business domain — making complex systems understandable, maintainable, and correct.
Why DDD#
Most architectural problems are not technical — they are modeling problems. DDD provides a framework to:
- Build software that speaks the same language as the business
- Draw boundaries that prevent monolith-style coupling
- Manage complexity by isolating it into well-defined contexts
- Evolve systems without rewriting everything
Strategic DDD#
Strategic patterns define the big picture — how teams, systems, and models relate to each other.
Bounded Contexts#
A bounded context is a boundary within which a domain model has a specific, consistent meaning.
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Sales Context │ │ Shipping Context │ │ Billing Context │
│ │ │ │ │ │
│ Customer = │ │ Customer = │ │ Customer = │
│ "prospect with │ │ "delivery │ │ "account with │
│ a shopping │ │ address and │ │ payment methods │
│ cart" │ │ preferences" │ │ and invoices" │
└─────────────────┘ └─────────────────┘ └──────────────────┘
The same word — "Customer" — means different things in each context. DDD makes this explicit instead of forcing a single god-model.
Ubiquitous Language#
Within a bounded context, developers and domain experts share one language. If the business says "Order", the code has an Order class — not PurchaseRequest or TransactionRecord.
Rules:
- Terms are defined per bounded context, not globally
- Code, tests, and documentation all use the same terms
- If the language changes, the code changes
- Ambiguity in language signals a missing concept or a context boundary
Context Mapping#
Context maps describe how bounded contexts relate to each other:
┌────────────────────────────────────────────────┐
│ Context Map │
│ │
│ Sales ──── Customer/Supplier ──── Shipping │
│ │ │ │
│ │ Shared Kernel │ │
│ │ ┌──────────┐ │ │
│ └────────│ Product │────────────────┘ │
│ │ Catalog │ │
│ └──────────┘ │
│ │
│ Billing ── Anti-Corruption Layer ── Legacy ERP │
└────────────────────────────────────────────────┘
Relationship patterns:
| Pattern | Description |
|---|---|
| Shared Kernel | Two contexts share a small, co-owned model |
| Customer/Supplier | Upstream context serves downstream; downstream has input on changes |
| Conformist | Downstream conforms to upstream model with no influence |
| Anti-Corruption Layer | Downstream translates upstream model to protect its own model |
| Open Host Service | Upstream publishes a well-defined protocol for all consumers |
| Published Language | Shared interchange format (e.g., Protobuf, JSON Schema) |
| Separate Ways | No integration — contexts operate independently |
Tactical DDD#
Tactical patterns define the building blocks inside a bounded context.
Entities#
Objects defined by their identity, not their attributes. Two orders with the same items are still different orders.
class Order {
readonly id: OrderId; // identity
private items: OrderItem[];
private status: OrderStatus;
addItem(product: ProductId, quantity: number): void {
if (this.status !== OrderStatus.Draft) {
throw new Error("Cannot modify a submitted order");
}
this.items.push(new OrderItem(product, quantity));
}
}
Value Objects#
Objects defined by their attributes. Two Money(10, "USD") instances are identical and interchangeable. Value objects are immutable.
class Money {
constructor(
readonly amount: number,
readonly currency: string
) {
if (amount < 0) throw new Error("Amount cannot be negative");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Currency mismatch");
}
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
Aggregates#
An aggregate is a cluster of entities and value objects with a single root entity that enforces invariants. External objects can only reference the aggregate root.
┌─────────────────────────────────┐
│ Order (Aggregate Root) │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ OrderItem │ │ OrderItem │ │
│ └────────────┘ └────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ ShippingAddress │ │
│ │ (Value Object) │ │
│ └─────────────────────┘ │
└─────────────────────────────────┘
Rule: Only the Order root is referenced externally.
Rule: All modifications go through Order methods.
Rule: One transaction = one aggregate.
Aggregate design rules:
- Keep aggregates small — prefer fewer entities per aggregate
- Reference other aggregates by ID, not by object reference
- Use eventual consistency between aggregates
- One transaction modifies one aggregate
Domain Events#
Something meaningful that happened in the domain. Events are named in past tense using ubiquitous language.
interface DomainEvent {
readonly occurredOn: Date;
readonly aggregateId: string;
}
class OrderPlaced implements DomainEvent {
constructor(
readonly aggregateId: string,
readonly customerId: string,
readonly totalAmount: Money,
readonly occurredOn: Date = new Date()
) {}
}
// Aggregate publishes events
class Order {
private events: DomainEvent[] = [];
place(): void {
this.status = OrderStatus.Placed;
this.events.push(new OrderPlaced(this.id, this.customerId, this.total()));
}
pullEvents(): DomainEvent[] {
const pending = [...this.events];
this.events = [];
return pending;
}
}
Repositories#
Repositories provide a collection-like interface for retrieving and persisting aggregates. They hide storage details from the domain.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
nextId(): OrderId;
}
// Infrastructure implementation
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? this.toDomain(row) : null;
}
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ...",
this.toRow(order)
);
}
}
Domain Services#
When business logic does not naturally belong to any single entity or value object, it goes into a domain service.
class PricingService {
calculateDiscount(customer: Customer, order: Order): Money {
if (customer.tier === "gold" && order.total().amount > 100) {
return order.total().multiply(0.1);
}
return Money.zero(order.total().currency);
}
}
The Anti-Corruption Layer (ACL)#
When integrating with legacy systems or external APIs, the ACL translates foreign models into your domain model — preventing outside concepts from polluting your bounded context.
Your Bounded Context Legacy System
┌──────────────────┐ ┌──────────────┐
│ │ ACL │ │
│ Domain Model │◄────────│ Legacy API │
│ (clean) │ adapts │ (messy) │
│ │ │ │
└──────────────────┘ └──────────────┘
// ACL adapter
class LegacyInventoryAdapter implements InventoryService {
constructor(private legacyClient: LegacyERPClient) {}
async checkStock(productId: ProductId): Promise<StockLevel> {
// Legacy returns XML with different field names
const raw = await this.legacyClient.getItemAvailability(productId.value);
// Translate to our domain model
return new StockLevel(
productId,
raw.QTY_ON_HAND - raw.QTY_RESERVED,
raw.WAREHOUSE_CODE
);
}
}
DDD and Microservices#
Bounded contexts map naturally to microservice boundaries:
- One bounded context = one microservice (or a small group)
- Context maps become API contracts or message schemas
- Domain events become integration events on a message bus
- Anti-corruption layers become API gateways or adapter services
When to Use DDD#
Use DDD when:
- The domain is complex with many business rules
- Multiple teams work on the same system
- The business logic changes frequently
- You are building microservices and need clear boundaries
Skip DDD when:
- The application is pure CRUD with little business logic
- The team is small and the domain is simple
- You are building a prototype or throwaway code
Domain-Driven Design is not a framework — it is a discipline for building software that mirrors business reality. Start with strategic patterns to draw boundaries, then apply tactical patterns within each context.
Ready to architect better systems? Explore 244 engineering articles on codelit.io.
Try these templates
Scalable SaaS Application
Modern SaaS with microservices, event-driven processing, and multi-tenant architecture.
10 componentsFigma Collaborative Design Platform
Browser-based design tool with real-time multiplayer editing, component libraries, and developer handoff.
10 componentsMicroservices with API Gateway
Microservices architecture with API gateway, service discovery, circuit breakers, and distributed tracing.
10 componentsContinue learning
Go deeper on microservices and integration
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Building Microservices
Sam Newman · 2021
600 pages on splitting a monolith: service boundaries, data decomposition, migration order.
Building Event-Driven Microservices
Adam Bellemare · 2025
2025 rewrite on event schemas, topic ownership, and what breaks when teams share streams.
4.7 (8)Kindle editionRelease It!
Michael T. Nygard · 2018
Post-mortems of real outages, then the stability patterns (circuit breaker, bulkhead) that stop them.
4.6 (501)#4 in Client-Server Networking SystemsA Philosophy of Software Design
John Ousterhout · 2021
Short book on module depth and complexity, with before/after code showing why an interface is too wide.