Domain-Driven Design
Designing software around the business domain: ubiquitous language, bounded contexts and context maps, entities, value objects, aggregates and domain events.
What domain-driven design is
Most hard problems in business software are not technical. They are about the business itself: what exactly an advance payment is, when a contract counts as ended, what happens to a meter reading that arrives after the annual statement was sent. Code that gets those rules subtly wrong is wrong, however well it is structured.
Domain-driven design (DDD) is an approach to software design that models the code on the business domain, the subject area the software serves, in close and continuous collaboration with the people who know that domain best, and that splits a large domain into parts with their own model. The name comes from Eric Evans' book Domain-Driven Design: Tackling Complexity in the Heart of Software (2003). Evans later published a short DDD Reference (2015) under a Creative Commons license, which summarizes the patterns and adds a few that were not in the book.
DDD has two halves:
- Strategic design: the shared language, the boundaries between models (bounded contexts) and how those parts relate (context maps). This is architecture: it decides where the lines between modules, teams and services go.
- Tactical design: the building blocks inside one context, such as entities, value objects, aggregates and domain events. This is design at the level of classes.
Both are on this page, in that order, because that is also the order of importance. The examples follow Kestrel Energy's billing, where Femke Jansen (head of billing) and Lucía Romero (billing clerk) are the domain experts and Joost Bakker owns the billing system KBS.
Ubiquitous language
A
ubiquitous language is a
shared vocabulary that developers and domain experts build together
and use everywhere within one part of the system: in conversations,
tickets, documentation, tests and the code itself. If Lucía says
"annual statement", the class is called
AnnualStatement, not
YearlyInvoiceSummaryDTO, and if a word in the code has
no meaning for the business, that is a sign that the model has
drifted from the domain.
The language is discovered, not invented. At Kestrel, the first modeling sessions showed that developers and the billing department used the same word for different things:
| Word | Billing department | Developers |
|---|---|---|
| connection | the physical connection of an address to the power grid, identified by an EAN code | a database connection |
| statement | the annual statement: the yearly settlement of advance payments against actual usage | an SQL statement |
| advance payment | the fixed monthly amount a customer pays, based on the estimated annual usage |
(no word; the code called it monthly_fee, which
billing staff read as an extra charge)
|
The outcome was a short glossary in the billing team's documentation
(see
Writing Tickets and Technical Documentation), the rename of monthly_fee to
advance_payment, and a team habit: in billing code and
billing conversations, "connection" means the grid connection, and
database connections are called "database connections" in full.
Bounded contexts
One shared language for the whole company sounds attractive, but it breaks down quickly. Ask three departments at Kestrel what a "customer" is:
- Customer Portal: someone who logs in, with an email address, a password and multi-factor authentication.
- Billing: a debtor, with a bank account, a payment method and an open balance.
- Metering: whoever lives at a connection address, with a move-in date, whether or not they have ever logged in.
All three are right. A single Customer class that
satisfies all of them collects dozens of fields, most of them
irrelevant to any one user, and every change for one department
risks breaking the other two. A
bounded context is an
explicit boundary, usually a subsystem or the work of one team,
within which one particular model and its language are defined and
consistent. Inside the boundary every term has exactly one meaning;
across boundaries the same word may mean something else, and that is
allowed.
Kestrel's main bounded contexts are Metering (meter-api, which receives readings), Billing (KBS), the Customer Portal and Customer Service. Each keeps its own model of a customer; they link the models through identifiers such as the customer number and the EAN code, and translate where they meet.
Context maps
A context map is a diagram and description of the bounded contexts in a system and of the relationships between them: who depends on whom, whose model wins where they meet, and how data is translated. It documents the situation as it is, including the messy parts, not as it should be.
The relationships have names, from the DDD Reference. The ones Kestrel uses, and a few common others:
| Relationship | What it means | At Kestrel |
|---|---|---|
| Customer/supplier | the downstream team's needs are planned into the upstream team's work | Metering publishes readings in the form Billing needs |
| Open-host service + published language | the upstream side offers one documented interface and data format for all consumers |
KBS's planned AnnualStatement JSON API, with a
published schema
|
| Conformist | the downstream side simply adopts the upstream model, because it cannot influence it | the portal uses the payment provider's model as is |
| Anti-corruption layer | the downstream side translates the upstream model into its own | new code that has to read from KBS |
| Shared kernel | two teams share a small part of the model and change it only together; keep it small | (not used) |
| Separate ways | no integration at all; each side solves it alone | (not used) |
| Big ball of mud | a part without clear model boundaries; draw a line around it and do not let its mess spread | KBS today, which the portal reads through a shared database |
The last row is the honest part of the map. Today the portal, meter-api and KBS all read and write the same database on db01, and that shared database couples all three, as the connection leak during incident INC-22047 showed (see Architecture Anti-Patterns). The target map above replaces it step by step; how to change a running system that way is on Evolving Architecture.
Anti-corruption layer
New code that must talk to KBS faces a choice: speak KBS's language
(column names such as AANSL_NR, one-letter status
codes, customer numbers without a prefix) or translate. An
anti-corruption layer
(ACL; Evans writes "anticorruption layer") is a translating layer
between your own model and a system whose model you do not control,
usually a legacy or external system, so that its concepts and quirks
do not leak into your code. It is typically built from the
adapters and
facades of the
previous pages:
from dataclasses import dataclass
from enum import Enum
# --- KBS's model (upstream, not ours to change) -----------------------------
def kbs_connection_row(ean):
rows = {
"E-0412": {"AANSL_NR": "E-0412", "STAT": "A", "KLANTNR": 10442},
"E-0413": {"AANSL_NR": "E-0413", "STAT": "L", "KLANTNR": None},
"E-0414": {"AANSL_NR": "E-0414", "STAT": "X", "KLANTNR": 10587},
}
return rows[ean]
# --- Metering's own model ------------------------------------------------------
class Billing(Enum):
BILLED = "billed"
VACANT = "vacant"
@dataclass(frozen=True)
class ConnectionStatus:
ean: str
billing: Billing
debtor: str | None
# --- Anti-corruption layer: the only code that knows KBS's words -------------
class KbsConnections:
_STATUS = {"A": Billing.BILLED, "L": Billing.VACANT} # A = active, L = leeg (empty)
def status(self, ean):
row = kbs_connection_row(ean)
try:
billing = self._STATUS[row["STAT"]]
except KeyError:
raise ValueError(f"{ean}: unknown KBS status {row['STAT']!r}") from None
debtor = f"C-{row['KLANTNR']}" if row["KLANTNR"] else None
return ConnectionStatus(row["AANSL_NR"], billing, debtor)
connections = KbsConnections()
for ean in ("E-0412", "E-0413", "E-0414"):
try:
print(connections.status(ean))
except ValueError as error:
print("refused:", error)
ConnectionStatus(ean='E-0412', billing=<Billing.BILLED: 'billed'>, debtor='C-10442') ConnectionStatus(ean='E-0413', billing=<Billing.VACANT: 'vacant'>, debtor=None) refused: E-0414: unknown KBS status 'X'
Metering's code works with Billing.BILLED and
"C-10442" and never sees STAT or
KLANTNR. The layer also fails closed: a status code it
does not know is refused, not silently treated as "billed". When KBS
is eventually replaced, only KbsConnections changes.
Entities and value objects
Inside a bounded context, DDD distinguishes two kinds of objects by one question: does it matter which one it is?
An entity is an object defined by its identity rather than by its attributes. A customer who moves house, changes their name and switches bank accounts is still the same customer; two customers who happen to have the same name and address are still two customers. Entities have an identifier that never changes, and two entities are equal when their identifiers are:
class Customer:
"""Entity: defined by its identity, which never changes."""
def __init__(self, customer_id, name, address):
self.customer_id = customer_id
self.name = name
self.address = address
def __eq__(self, other):
return isinstance(other, Customer) and other.customer_id == self.customer_id
def __hash__(self):
return hash(self.customer_id)
before = Customer("C-10442", "A. de Jong", "Lindelaan 4, Utrecht")
after = Customer("C-10442", "A. de Jong", "Kade 17, Zwolle") # moved
namesake = Customer("C-20931", "A. de Jong", "Lindelaan 4, Utrecht") # same name
print(before == after) # same customer, different attributes
print(before == namesake) # same attributes, different customer
True False
A value object is an object defined only by its values, with no identity of its own: an amount of money, a quantity of kilowatt-hours, a billing period, an EAN code. Two value objects with the same values are interchangeable. Value objects are immutable: an operation returns a new value instead of changing the old one. That makes them safe to share, and it gives a natural home to the rules about the value. In Python, a frozen dataclass does most of the work:
from dataclasses import dataclass, FrozenInstanceError
from decimal import Decimal
@dataclass(frozen=True)
class Money:
"""Value object: defined by its values, immutable, no identity."""
amount: Decimal
currency: str = "EUR"
def __post_init__(self):
if self.amount.as_tuple().exponent < -2:
raise ValueError(f"more than 2 decimals: {self.amount}")
def __add__(self, other):
if other.currency != self.currency:
raise ValueError(f"cannot add {other.currency} to {self.currency}")
return Money(self.amount + other.amount, self.currency)
a = Money(Decimal("95.00"))
b = Money(Decimal("95.00"))
print(a == b, a is b) # equal by value, two objects
print(a + Money(Decimal("12.40"))) # operations return a NEW value
try:
a.amount = Decimal("0") # values never change
except FrozenInstanceError as error:
print("FrozenInstanceError:", error)
for bad in (lambda: a + Money(Decimal("10.00"), "GBP"),
lambda: Money(Decimal("0.285"))):
try:
bad()
except ValueError as error:
print("ValueError:", error)
True False
Money(amount=Decimal('107.40'), currency='EUR')
FrozenInstanceError: cannot assign to field 'amount'
ValueError: cannot add GBP to EUR
ValueError: more than 2 decimals: 0.285
The rules "money has at most two decimals" and "never add euros to
pounds" now live in one class instead of in every function that
handles amounts. Languages with records make value objects even
shorter. A
C# record
gets value equality, a readable ToString() and
non-destructive changes with with from a single line:
var advance = new Money(95.00m, "EUR");
var same = new Money(95.00m, "EUR");
Console.WriteLine(advance == same); // value equality
Console.WriteLine(ReferenceEquals(advance, same)); // two objects
Console.WriteLine(advance + new Money(12.40m, "EUR"));
var lower = advance with { Amount = 90.00m }; // a changed copy
Console.WriteLine($"{advance.Amount} -> {lower.Amount}");
try
{
Console.WriteLine(advance + new Money(10.00m, "GBP"));
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
// A value object in one line, plus the one rule it must enforce.
record Money(decimal Amount, string Currency)
{
public static Money operator +(Money a, Money b) =>
a.Currency == b.Currency
? new Money(a.Amount + b.Amount, a.Currency)
: throw new InvalidOperationException($"cannot add {b.Currency} to {a.Currency}");
}
True
False
Money { Amount = 107.40, Currency = EUR }
95.00 -> 90.00
cannot add GBP to EUR
A Java record works the same way. Most objects in a well-designed domain model are value objects; entities are the smaller number of things whose history you follow over time.
Aggregates
Some business rules involve several objects at once. Kestrel's rule for advance payments is: the twelve monthly payments of a contract year add up to the agreed annual estimate. If any code could change one monthly payment directly, the rule would hold only as long as every piece of code remembered it.
An aggregate is a cluster of entities and value objects that is treated as one unit for changes, with one entity as its root. Outside code may hold a reference only to the root and must make every change through it, so the root can enforce the rules (invariants) for the whole cluster, and each change to an aggregate is saved in one database transaction. Here, the contract is the root and the advance payments are inside it:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class AdvancePaymentsChanged: # domain event: something that happened
contract_id: str
from_month: int
monthly_amount: Decimal
class Contract:
"""Aggregate root. Rule: the 12 advance payments add up to the annual estimate."""
def __init__(self, contract_id, ean, annual_estimate):
self.contract_id = contract_id
self.ean = ean # a reference to the meter, by identity only
self.events = []
self._paid_months = 0
self._payments = self._spread(Decimal(annual_estimate), 12)
@staticmethod
def _spread(total, months):
monthly = (total / months).quantize(Decimal("0.01"))
return [monthly] * (months - 1) + [total - monthly * (months - 1)]
@property
def payments(self):
return tuple(self._payments) # outsiders get a read-only copy
def record_payment(self):
self._paid_months += 1
def change_estimate(self, new_estimate):
new_estimate = Decimal(new_estimate)
paid = sum(self._payments[:self._paid_months])
if new_estimate < paid:
raise ValueError(f"{self.contract_id}: estimate {new_estimate} is below "
f"the {paid} already paid")
open_months = 12 - self._paid_months
self._payments[self._paid_months:] = self._spread(new_estimate - paid, open_months)
self.events.append(AdvancePaymentsChanged(
self.contract_id, self._paid_months + 1, self._payments[self._paid_months]))
contract = Contract("CT-20417", ean="E-0412", annual_estimate="1140.00")
for _ in range(4):
contract.record_payment()
contract.change_estimate("1500.00") # heat pump installed in May
print(" ".join(str(amount) for amount in contract.payments))
print("total:", sum(contract.payments))
print(contract.events)
try:
contract.change_estimate("300.00")
except ValueError as error:
print("refused:", error)
95.00 95.00 95.00 95.00 140.00 140.00 140.00 140.00 140.00 140.00 140.00 140.00
total: 1500.00
[AdvancePaymentsChanged(contract_id='CT-20417', from_month=5, monthly_amount=Decimal('140.00'))]
refused: CT-20417: estimate 300.00 is below the 380.00 already paid
The schedule is private; payments returns a read-only
tuple, and the only way to change the payments is
change_estimate(), which keeps the sum right and
refuses an estimate below what has already been paid. Vaughn
Vernon's widely cited "Effective Aggregate Design" articles
(2011–2012) give four rules of thumb that this example follows:
- Model true invariants in consistency boundaries. The boundary is drawn around a rule that must always hold, not around everything that is related.
- Design small aggregates. The contract does not contain its meter readings: a meter produces 35,040 readings a year, and loading or locking all of them to change an advance payment would be slow and would block meter-api.
- Reference other aggregates by identity. The contract holds the EAN code, not the meter reading objects.
- Use eventual consistency outside the boundary. Changes that affect other aggregates happen afterward, triggered by domain events, in separate transactions. Martin Fowler's short version: transactions should not cross aggregate boundaries.
To load and save aggregates, DDD uses a repository (the DDD
pattern, unrelated to a Git repository): an object that looks like
an in-memory collection of aggregates, such as
contracts.get("CT-20417") and
contracts.save(contract), and hides the database behind
it. Where it sits in the layers of an application is on
Layers and Boundaries.
Domain events
In the example, change_estimate() also appended an
AdvancePaymentsChanged object to
contract.events. A
domain event is an immutable
record of something that happened in the domain that domain experts
care about, named in the past tense in the ubiquitous language:
AdvancePaymentsChanged, ContractEnded,
MeterReplaced. Domain events are how one aggregate
causes changes in others without a shared transaction: after the
contract is saved, the events are handed to whoever needs them, for
example a handler that emails the customer the new amount or one
that updates the direct-debit instruction at the bank.
Domain events were not one of the patterns in Evans' 2003 book; the DDD Reference marks them as a term introduced since. Inside one program they are delivered much like the observer pattern; between services they become messages through a broker, and saving the aggregate and publishing its events reliably is the problem the transactional outbox solves, described on Event-Driven Architecture.
When DDD is worth it
DDD costs time: modeling sessions with domain experts, a language to maintain, translations between contexts. It pays off where the business rules are complex and change often, which is why billing is the classic example. It does not pay off for an internal tool that mostly stores and shows records (create, read, update, delete), or for a technical system such as a log shipper, where there is no business domain to model.
You can also take the parts selectively. The strategic ideas are useful almost everywhere: a shared language prevents misunderstandings in any project, and thinking in bounded contexts is one of the best guides for drawing module and service boundaries. The tactical patterns are optional per context; value objects are the cheapest and most broadly useful of them.