Domain-Driven Design (DDD)
Eric Evans, Domain-Driven Design (2003). A set of concepts and patterns for modeling complex business domains in code. Not an architecture per se — it’s a way of thinking about software, often paired with Clean / Hexagonal Architecture.
DDD has two halves: strategic (how to break a system into bounded contexts) and tactical (how to model within a context — entities, value objects, aggregates).
For Clean Architecture context see 09_clean_architecture.md. For repositories see 14_repository_unit_of_work.md.
The core idea
Software for complex domains (insurance, banking, healthcare, logistics) is hard not because of technical complexity but domain complexity. The business rules are intricate; experts use vocabulary that maps to specific behaviors; misunderstandings between developers and domain experts cause bugs.
DDD’s prescription:
- Talk to domain experts. A lot.
- Build a ubiquitous language — vocabulary shared between code and the business.
- Model domain concepts as rich objects with behavior, not data bags.
- Split the system into bounded contexts where the language is consistent.
- Make architecture decisions that protect the domain model.
Strategic DDD — bounded contexts
A bounded context is a logical boundary within which one model is consistent.
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ Sales context │ │ Shipping context │ │ Billing context │
│ │ │ │ │ │
│ Customer: │ │ Customer: │ │ Customer: │
│ - id, name │ │ - id, address │ │ - id, payment_ │
│ - lead_score │ │ - delivery_pref │ │ method │
│ - opportunity │ │ │ │ - balance │
└───────────────────┘ └───────────────────┘ └───────────────────┘
Customer means different things in different contexts. Sales cares about lead score; Shipping cares about address; Billing cares about payment method. Trying to model ONE Customer class that satisfies all three creates a “god class” that’s impossible to evolve.
DDD says: let each context have its own model. They share an ID (the same human customer); they don’t share the implementation.
Context map
The picture of how bounded contexts relate:
| Pattern | Meaning |
|---|---|
| Partnership | two teams coordinate (high coupling) |
| Shared kernel | shared code between contexts (rare — usually problematic) |
| Customer-Supplier | downstream context depends on upstream’s API |
| Conformist | downstream just accepts whatever upstream gives |
| Anti-Corruption Layer (ACL) | downstream translates upstream’s model to its own (decouples) |
| Open-Host Service | upstream publishes a public API |
| Published Language | shared protocol (events, JSON schemas) |
| Separate Ways | contexts intentionally don’t integrate |
The ACL is the most-applied pattern. When you’re integrating with a system whose model doesn’t fit yours, build a translation layer.
# Anti-corruption layer between legacy CRM and our domain
class LegacyCRMAdapter:
def __init__(self, legacy_client):
self.legacy = legacy_client
def find_customer(self, customer_id: CustomerId) -> Customer:
# Legacy returns dict with weird field names
raw = self.legacy.GetClientRecord(client_id=customer_id.value)
# Translate to our domain model
return Customer(
id=CustomerId(raw["client_id"]),
name=raw["fullname"],
email=Email(raw["email_addr"]),
)
Our domain stays clean; the adapter absorbs the legacy ugliness.
Tactical DDD — the building blocks
Within a bounded context, the domain model is built from:
| Concept | What |
|---|---|
| Entity | object with identity that persists over time (Customer, Order) |
| Value Object | object defined by its values, immutable (Money, Address, Email) |
| Aggregate | cluster of entities/VOs treated as a unit; has a root entity |
| Aggregate Root | the only entity outside code accesses directly |
| Repository | persists/retrieves aggregates |
| Domain Service | operations that don’t naturally fit on one entity |
| Domain Event | something that happened in the domain |
| Factory | encapsulates complex entity creation |
Entities
class Order:
def __init__(self, id: OrderId, customer_id: CustomerId):
self.id = id # identity
self.customer_id = customer_id
self.items: list[OrderItem] = []
self.status = OrderStatus.DRAFT
def add_item(self, product_id: ProductId, quantity: int, price: Money):
if self.status != OrderStatus.DRAFT:
raise OrderLockedError
self.items.append(OrderItem(product_id, quantity, price))
def total(self) -> Money:
return sum((item.subtotal() for item in self.items), Money.zero())
Identity matters. Two Order objects with the same data but different IDs are different orders. Compare by ID, not by value.
Value Objects
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise CurrencyMismatchError
return Money(self.amount + other.amount, self.currency)
def __mul__(self, factor: int) -> "Money":
return Money(self.amount * factor, self.currency)
No identity. Two Money(100, "USD") are interchangeable. Immutable (frozen dataclass). Operations return new instances.
Value objects are the heroes of rich domain models. They turn primitive obsession into typed concepts:
# Primitive obsession
def transfer(from_id: str, to_id: str, amount: float, currency: str): ...
# Value objects
def transfer(from_id: AccountId, to_id: AccountId, amount: Money): ...
Now transfer("a", "b", -100, "USD") is impossible — Money(-100, "USD") doesn’t validate.
Aggregates
A cluster of entities + value objects treated as one consistency boundary:
class Order: # Aggregate Root
def __init__(self, id, customer_id):
self.id = id
self.customer_id = customer_id
self.items: list[OrderItem] = [] # part of the Order aggregate
def add_item(self, product, qty, price):
# Invariants enforced here
if len(self.items) >= 100:
raise TooManyItemsError
self.items.append(OrderItem(product, qty, price))
class OrderItem: # Internal entity (not root)
def __init__(self, product_id, quantity, price):
self.product_id = product_id
self.quantity = quantity
self.price = price
Rules:
- One repository per aggregate root.
OrderRepositorysavesOrder(and its items). NoOrderItemRepository. - External code only references the root.
OrderItemis never accessed directly from outsideOrder. - Aggregates are transactional boundaries. Operations on an aggregate are atomic; cross-aggregate operations are eventually consistent.
- Reference other aggregates by ID, not direct reference.
Order.customer_id, notOrder.customer. Loading a customer is the use case’s job.
The aggregate boundary is one of the hardest design decisions in DDD. Too big = lock contention. Too small = invariants can’t be enforced.
Repositories
Persistence abstraction over the aggregate:
class OrderRepository(Protocol):
def get(self, id: OrderId) -> Order | None: ...
def save(self, order: Order) -> None: ...
def delete(self, order: Order) -> None: ...
The repository hides the DB. Domain code knows nothing of SQL. See 14_repository_unit_of_work.md.
Domain Services
When an operation doesn’t naturally fit on one entity:
class TransferService:
def transfer(self, from_account: Account, to_account: Account, amount: Money):
from_account.withdraw(amount)
to_account.deposit(amount)
The action involves two aggregates. Putting transfer on Account is awkward (which account “owns” the transfer?). A domain service captures it.
Domain services contain business logic. They’re not the same as application services (use cases), which coordinate and orchestrate.
Domain Events
Things that happened, captured as objects:
@dataclass(frozen=True)
class OrderPlaced:
order_id: OrderId
customer_id: CustomerId
total: Money
placed_at: datetime
# In the entity:
class Order:
def place(self):
if self.status != OrderStatus.DRAFT:
raise OrderAlreadyPlacedError
self.status = OrderStatus.PLACED
self.events.append(OrderPlaced(self.id, self.customer_id, self.total(), now()))
Events drive integrations:
- Send confirmation email (when OrderPlaced).
- Reserve inventory (when OrderPlaced).
- Update analytics (when OrderPlaced).
Pair with event sourcing for full state-as-events. See 13_cqrs_event_sourcing.md.
Ubiquitous Language
The vocabulary used in conversations between developers and domain experts. Same words in the meeting room and the code.
# Bad — generic vocabulary
class User:
def add_widget(self, w): ...
# Good — domain vocabulary
class Subscriber: # the word the business uses
def upgrade_to_premium(self): ... # the action the business cares about
If the business says “subscriber upgrades to premium,” the code says subscriber.upgrade_to_premium(). Not user.update_status("paid").
When developers and business experts use different words, every conversation requires translation, and every translation is a chance to misunderstand. Ubiquitous language eliminates the gap.
Glossaries, ADRs, or in-code docstrings help. The names of classes, methods, and modules ARE the language.
Strategic vs tactical — when to apply
Strategic DDD (bounded contexts, context maps, ACLs) — pays off when:
- System is large.
- Multiple teams work on it.
- Different parts of the business use different models for the same concept.
Tactical DDD (entities, value objects, aggregates, repositories) — pays off when:
- Domain has real complexity (not just CRUD).
- Business rules are intricate.
- You need to test domain logic in isolation.
A small CRUD app with one team doesn’t need any of it. A large enterprise with 10 contexts and complex domain rules benefits enormously.
DDD ≠ folder structure
DDD is a way of thinking. The folder structure is incidental:
# One possible structure
src/
contexts/
sales/
domain/
application/
adapters/
shipping/
domain/
application/
adapters/
Or could be monolithic (all in one folder) or microservices (one service per context). The structure follows from the design; don’t reverse-engineer DDD from folders.
Common pitfalls
- DDD-by-naming: classes called
OrderEntity,OrderValueObject,OrderAggregate,OrderRepository— without changing the actual design. Cosmetic DDD. - Anemic domain: putting all logic in services; entities are dataclasses. Defeats the purpose; see 09_clean_architecture.md.
- Aggregate too big: loading “user with all 10000 orders” to add one item. Aggregate boundaries should be tight.
- Cross-aggregate transactions: trying to update two aggregates atomically — DDD says use eventual consistency between aggregates (events / sagas).
- DDD for CRUD: a CMS or admin tool doesn’t need a rich domain. DDD adds friction.
- Ignoring strategic DDD: applying tactical patterns inside a monolithic context that should be multiple. Ends in a “domain” too large to model coherently.
Common interview confusions
- “DDD is just clean architecture.” — overlap; DDD predates Clean. DDD is about modeling; Clean is about layering. Pair them.
- “DDD requires microservices.” — no. Bounded contexts can live in a monolith (separate modules) or microservices (separate services). DDD just says they should be cleanly separated.
- “Aggregates are tables.” — aggregates are domain concepts; their persistence may span multiple tables (one table for the aggregate, others for sub-entities). Or use a document DB and store as one document.
Interview angle
- “What is Domain-Driven Design?” — a set of concepts (Eric Evans, 2003) for modeling complex business domains. Two halves: strategic (bounded contexts, context maps) for splitting systems; tactical (entities, value objects, aggregates, repositories, services, events) for modeling within a context. Pairs with Clean / Hexagonal Architecture.
- “What’s a bounded context?” — a logical boundary within which one domain model is consistent. The same concept (e.g., “Customer”) can be modeled differently in different contexts (Sales vs Shipping vs Billing). Avoid the “god class” trap.
- “Entity vs Value Object?” — entities have identity that persists (Order, Customer). Value objects are defined by their values (Money, Address) — immutable, equality by content. Use value objects to escape primitive obsession.
- “What’s an Aggregate?” — a cluster of entities and value objects treated as one consistency boundary. One root entity is the only thing external code references. Repositories work per-aggregate-root. Boundaries are transactional units.
- “What’s an Anti-Corruption Layer?” — a translation layer between bounded contexts. Protects your clean domain model from an upstream system whose model doesn’t fit yours. Common when integrating legacy systems.
- “What’s Ubiquitous Language?” — shared vocabulary between developers and domain experts. Same words in the meeting and the code. Class and method names ARE the language. Eliminates translation gaps and misunderstandings.
- “DDD vs CRUD?” — DDD is for complex domains with non-trivial business rules. CRUD apps have no real domain — just data being created/read/updated/deleted. DDD adds friction for CRUD; it pays off for genuine complexity.
- “Strategic vs tactical DDD?” — strategic = how to split a system (bounded contexts, context maps). Tactical = how to model within a context (entities, aggregates, etc.). Both useful; strategic matters more at scale.