Domain-Driven Design (DDD): From Strategic Patterns to Tactical Implementation
Domain-Driven Design (DDD)#
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 it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
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 componentsBuild this architecture
Generate an interactive architecture for Domain in seconds.
Try it in Codelit →
Comments