Domain-Driven Design
Aligning software design with the business domain. DDD provides a vocabulary and set of patterns for modelling complex business domains — making the code reflect the real world rather than database ta...
Aligning software design with the business domain. DDD provides a vocabulary and set of patterns for modelling complex business domains. Making the code reflect the real world rather than database tables.
Strategic Design
Bounded Context:
A explicit boundary within which a domain model applies consistently.
"Customer" in the Sales context is different from "Customer" in the Support context.
Each BC has its own code, team, and data model.
Ubiquitous Language:
A shared vocabulary between developers and domain experts.
Used in: code (class names, method names), tests, conversations, documentation.
"Place an order" not "insert into orders table"
"Cancel a booking" not "set status=0"
Context Map:
Documents relationships between bounded contexts.
Relationship types:
- Shared Kernel: two BCs share a common model
- Customer/Supplier: upstream BC defines API for downstream
- Conformist: downstream adopts upstream model entirely
- Anti-Corruption Layer: downstream translates to its own model
- Separate Ways: no integration, duplicate if needed
Tactical Design Patterns
Entity:
Has a unique identity that persists across state changes.
Two customers with the same name are still different entities.
Identity is what makes them the same, not attribute equality.
Value Object:
Defined entirely by its attributes. No identity.
Two Money(100, "GBP") instances are equal.
Immutable — no setters, only create new instances.
Money(100, "GBP").add(Money(50, "GBP")) → Money(150, "GBP")
Aggregate:
A cluster of entities and value objects treated as a unit.
Has a single Aggregate Root — the only entry point for external interaction.
Ensures consistency within its boundary.
Loaded and saved as a whole.
Domain Event:
Something that happened in the domain. Past tense. Immutable.
OrderPlaced, PaymentReceived, ItemShipped.
Events cross bounded context boundaries.
Repository:
Abstraction for storing and retrieving aggregates.
Collection-like interface. Hides persistence details.
One repository per aggregate root.
Domain Service:
Operation that doesn't belong on any single entity.
Stateless. Named after a domain concept.
"Transfer funds from account A to account B" (involves two entities).
Value Objects
# Value objects: immutable, equality by value
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True) # frozen=True makes it immutable
class Money:
amount: Decimal
currency: str
def __post_init__(self):
if self.amount < 0:
raise ValueError("Money amount cannot be negative")
if len(self.currency) != 3:
raise ValueError("Currency must be 3-letter ISO code")
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def multiply(self, factor: Decimal) -> "Money":
return Money(self.amount * factor, self.currency)
def __str__(self) -> str:
return f"{self.amount:.2f} {self.currency}"
@dataclass(frozen=True)
class Address:
line1: str
city: str
country: str
postcode: str
def __post_init__(self):
if not self.country or len(self.country) != 2:
raise ValueError("Country must be 2-letter ISO code")
@dataclass(frozen=True)
class Email:
value: str
def __post_init__(self):
import re
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', self.value):
raise ValueError(f"Invalid email: {self.value}")Aggregate Root
# Order aggregate — enforces all order invariants
class Order:
"""Aggregate root for the Order aggregate.
All changes to order or its items go through this class."""
def __init__(self, order_id: str, customer_id: str):
self.id = order_id
self.customer_id = customer_id
self._items: list[OrderItem] = []
self._status = OrderStatus.DRAFT
self._events: list = []
def add_item(self, product_id: str, quantity: int, unit_price: Money):
if self._status != OrderStatus.DRAFT:
raise DomainError(f"Cannot add items to {self._status} order")
if quantity <= 0:
raise DomainError("Quantity must be positive")
existing = next((i for i in self._items if i.product_id == product_id), None)
if existing:
existing.increase_quantity(quantity)
else:
self._items.append(OrderItem(product_id=product_id, quantity=quantity,
unit_price=unit_price))
def submit(self):
if not self._items:
raise DomainError("Cannot submit an empty order")
self._status = OrderStatus.SUBMITTED
self._events.append(OrderSubmitted(order_id=self.id, total=self.total()))
def cancel(self, reason: str):
if self._status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED):
raise DomainError(f"Cannot cancel {self._status} order")
self._status = OrderStatus.CANCELLED
self._events.append(OrderCancelled(order_id=self.id, reason=reason))
def total(self) -> Money:
if not self._items:
return Money(Decimal("0"), "GBP")
totals = [item.subtotal() for item in self._items]
result = totals[0]
for t in totals[1:]:
result = result.add(t)
return result
def uncommitted_events(self) -> list:
events = list(self._events)
self._events.clear()
return eventsRepository
from typing import Protocol
class OrderRepository(Protocol):
def save(self, order: Order) -> None: ...
def get(self, order_id: str) -> Order | None: ...
def find_by_customer(self, customer_id: str) -> list[Order]: ...
# Concrete implementation
class PostgresOrderRepository:
def __init__(self, session):
self.session = session
self.mapper = OrderMapper() # maps between domain model and DB row
def save(self, order: Order) -> None:
db_row = self.mapper.to_db(order)
self.session.merge(db_row)
self.session.commit()
def get(self, order_id: str) -> Order | None:
db_row = self.session.query(OrderRecord).get(order_id)
return self.mapper.to_domain(db_row) if db_row else NoneBounded Context Example
E-commerce platform — three bounded contexts:
Catalogue BC:
Language: Product, Category, Variant, SKU, Availability
Owns: product DB, search index
Events emitted: ProductCreated, PriceChanged, ProductDiscontinued
Orders BC:
Language: Order, LineItem, Customer, Payment, Shipping
"Product" here is a snapshot (price locked at order time), not the live Catalogue product
Events emitted: OrderPlaced, OrderShipped, OrderCancelled
Inventory BC:
Language: StockItem, Warehouse, Reservation, Replenishment
"Product" here is a physical thing in a location
Events consumed: OrderPlaced → reserve stock
Events emitted: StockReserved, StockDepleted
Common Failure Cases
Domain logic leaking into the repository
Why: a save() method that also validates business rules, sends events, or updates related aggregates mixes persistence concerns with domain logic; the repository becomes untestable in isolation.
Detect: a repository method contains if branches that enforce domain invariants rather than simply persisting and loading the aggregate.
Fix: keep repositories to a single responsibility — load and store aggregates; move all invariant checks and domain rule enforcement into the aggregate or a domain service.
Aggregate boundary too large, causing lock contention
Why: an Order aggregate that includes all line items, the customer, payment details, and shipping history must be loaded and saved atomically; concurrent writes from different parts of the UI fight over the same aggregate root.
Detect: high update conflict rates in the optimistic_lock_version column, or slow aggregate saves under load.
Fix: split the aggregate along natural consistency boundaries — Order (items, status), Payment (payment attempts, result), and ShipmentTracking are separate aggregates that communicate via domain events.
Ubiquitous language not reflected in the code
Why: developers use record, entry, and data as variable names while the domain experts say order, booking, and reservation; divergence makes conversations between developers and domain experts confusing.
Detect: sit in a domain expert conversation and note every time you have to mentally translate between what they say and what the code is called.
Fix: rename code identifiers to match the ubiquitous language immediately when a mismatch is found; this is not a cosmetic change — it is the primary benefit of DDD.
Value objects mutated by callers
Why: a Money object is passed to a function which modifies its amount attribute directly (possible if frozen=False); other holders of the same object see unexpected state changes.
Detect: a unit test modifies an attribute on a value object and checks whether another reference sees the change.
Fix: declare all value objects with @dataclass(frozen=True); any "mutation" must produce a new instance via methods like .add() or .multiply().
Connections
se-hub · cs-fundamentals/architecture-patterns-se · cs-fundamentals/microservices-patterns · cs-fundamentals/event-driven-architecture · cs-fundamentals/cqrs-event-sourcing · cs-fundamentals/oop-patterns · cs-fundamentals/database-design
Open Questions
- What are the most common misapplications of this concept in production codebases?
- When should you explicitly choose not to use this pattern or technique?
Related reading