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.

Kestrel's context map. Metering is upstream of Billing in a customer/supplier relationship. Billing, which is KBS, is upstream of the Customer Portal; KBS offers an open-host service with a published language, and the portal protects itself with an anti-corruption layer. The external payment provider is upstream of the portal, which conforms to the provider's model. Metering meter-api Billing KBS Customer Portal portal Payment provider external U customer/supplier D U · OHS/PL ACL · D JSON over HTTP U conformist D
Kestrel's target context map. U marks the upstream side, whose model the other side depends on; D the downstream side. OHS/PL: open-host service with a published language; ACL: anti-corruption layer.

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:

The Contract aggregate: the Contract root with the method change_estimate, containing twelve advance payments. Outside the boundary, MeterReading aggregates; the contract refers to them only by the EAN code. Contract aggregate Contract (root) change_estimate() 95.00 95.00 … ×12 advance payments MeterReading aggregate MeterReading ean, time, kWh by EAN
Everything inside the dashed boundary is changed only through the contract. Meter readings are separate aggregates; the contract refers to the meter by its EAN code, not by holding the readings.
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.