Skip to content

What is Domain-Driven Design?

Domain-Driven Design (DDD) is an approach to building software in which the code is a model of the business it serves. Instead of starting from tables and endpoints, you start from the business itself: what it deals with, what can happen, who decides, and what people need to know. The code is then organized around those answers.

This page introduces the concepts DDD uses to do that, along with two techniques that are often paired with it, event sourcing and CQRS. The examples come from a bank, the same one the tutorial builds.

The domain is the area of activity your software serves. For a bank, that’s customers, accounts, deposits, withdrawals, transfers and fraud. The domain exists whether or not there’s any software; it’s the business as the people in it understand it.

A domain model is a deliberate simplification of the domain, written as code. It keeps what matters for the decisions the software has to make and leaves the rest out. A bank’s model needs to know an account’s balance, because withdrawals depend on it. It doesn’t need to know the colour of the customer’s card.

The model is about behaviour, not storage. It says what can happen in the domain, which rules govern it, and what’s remembered as a result. How that’s stored is a separate concern.

Here is the shape of the bank’s model, which the rest of this page explains piece by piece. A customer’s app sends commands; aggregates decide them and record events; views turn events into what the app reads; and policies turn events into further commands.

CommandsDepositMoneyAggregatesAccountEventsMoneyDepositedViewsAccountSummaryPoliciesFraudReview
CommandAggregateEventViewPolicy
Clients send commands and read views. Dashed edges happen shortly after the events are recorded.

DDD asks that the model and the people who run the business use the same words for the same things. If the bank says customers deposit money into an account, the code says DepositMoney and Account, not updateBalance and rows.

src/language.ts
export class AccountOpened extends Domain.Event("AccountOpened") {}
export class MoneyDeposited extends Domain.Event("MoneyDeposited") {}
export class InsufficientFunds extends Domain.Rejection("InsufficientFunds") {}

This shared vocabulary is called the ubiquitous language. It means a conversation about a rule turns directly into a change in the code, and reading the code tells you how the business works. When the business has a word for something, the model uses it. When the model needs a word the business lacks, that’s a sign something hasn’t been understood yet.

A command is a request to change something in the domain: open an account, deposit 100, withdraw 30. It’s named as an instruction, in the imperative.

A command is only a request. It can be refused: a withdrawal from an account that doesn’t hold enough money is turned down. So a command also declares the ways it can be refused.

src/WithdrawMoney.command.ts
export class WithdrawMoney extends Domain.Command("WithdrawMoney", {
target: AccountId,
input: { amount: Schema.Int },
events: [MoneyWithdrawn],
rejects: [NotOpen, InsufficientFunds],
reply: { balance: Schema.Int },
}) {}

See Command.

An event records something that has happened in the domain: an account was opened, money was deposited, money was withdrawn. It’s named in the past tense, because it’s a fact.

Facts can’t be refused or undone. If a deposit was a mistake, the bank doesn’t erase it; it records a correction as a new event, and the history shows both. So where a command may or may not succeed, an event already has.

src/WithdrawMoney.command.ts
export class MoneyWithdrawn extends Domain.Event("MoneyWithdrawn", {
amount: Schema.Int,
balanceAfter: Schema.Int,
}) {}

Commands and events are the two halves of every change. Someone asks with a command; if the request is accepted, events record what happened.

See Event.

Something has to decide whether a command is accepted. In DDD, that’s an aggregate: a cluster of related state together with the rules that govern it. A bank account is an aggregate. It holds a balance, and it enforces the rules about that balance, such as “a withdrawal can’t exceed the balance”.

Rules like that are called invariants: conditions that must be true at every moment, with no exceptions. An aggregate exists to protect its invariants. Every command that could affect them goes through the aggregate, which checks it against its current state and either refuses it or records events.

WithdrawMoneycommandAccount a-1aggregateMoneyWithdrawneventInsufficientFundsrejection
CommandAggregateEventRejection
An aggregate turns a command into events, or refuses it.

Each aggregate is also a consistency boundary. It handles one command at a time, so two withdrawals arriving together are decided one after the other, and the second sees the balance the first left behind. Everything inside the boundary is always consistent. Nothing outside it changes in the same step.

That makes the size of an aggregate a design decision. It should contain exactly the state its invariants need. A customer and their accounts are separate aggregates, because no rule requires a customer’s details and an account’s balance to change together.

Here is the whole account: how it decides each command, and how each event changes its state.

src/Account.aggregate.ts
export class Account extends Domain.Aggregate("Account", {
id: AccountId,
state: State,
initial: { _tag: "Unopened" },
commands: [OpenAccount, DepositMoney, WithdrawMoney],
decide: {
OpenAccount: (s, cmd) =>
s._tag !== "Unopened" ? new AlreadyOpen() : [new AccountOpened({ owner: cmd.owner })],
DepositMoney: (s, cmd) =>
s._tag !== "Open"
? new NotOpen()
: [new MoneyDeposited({ amount: cmd.amount, balanceAfter: s.balance + cmd.amount })],
WithdrawMoney: (s, cmd) =>
s._tag !== "Open"
? new NotOpen()
: cmd.amount > s.balance
? new InsufficientFunds({ balance: s.balance, requested: cmd.amount })
: [new MoneyWithdrawn({ amount: cmd.amount, balanceAfter: s.balance - cmd.amount })],
},
evolve: {
AccountOpened: (_, e) => ({ _tag: "Open" as const, owner: e.owner, balance: 0 }),
MoneyDeposited: (s, e) => ({ ...mustBeOpen(s), balance: e.balanceAfter }),
MoneyWithdrawn: (s, e) => ({ ...mustBeOpen(s), balance: e.balanceAfter }),
},
reply: {
DepositMoney: (s) => ({ balance: mustBeOpen(s).balance }),
WithdrawMoney: (s) => ({ balance: mustBeOpen(s).balance }),
},
}) {}

See Aggregate.

Most systems store the current state and overwrite it on every change. Event sourcing stores the events instead, and treats the current state as something derived from them.

An account’s balance is then the result of applying its events in order: opened, deposited 100, withdrew 30, so the balance is 70. Nothing about the account is lost. You can always answer why the balance is what it is, rebuild it from scratch, or derive a new kind of information from history you already have.

This is why an aggregate has two parts. Decide looks at the current state and a command, and returns events or a refusal. Evolve applies one event to the state to produce the next state. Decide may refuse; evolve never does, because the event it applies has already happened. Applying the same events always produces the same state.

balance: 100stateWithdrawMoney 30commanddecideMoneyWithdrawneventevolvebalance: 70state
CommandEvent

An account needs its balance to decide a withdrawal. It doesn’t need a statement, a list of this month’s transactions, or a total across every account a customer holds. Those are questions people ask, and each needs the data in a different shape.

CQRS, Command Query Responsibility Segregation, splits the system along that line. The command side handles changes: commands go to aggregates, which decide and record events. The query side answers questions: it builds read-only models from those same events, each shaped for the question it answers. The two sides share nothing but the events.

Commandschange somethingAggregatesdecideEventsthe recordViewsone per questionQueriesread the views
CommandAggregateEventViewQuery
The command side records events. The query side builds views from them, and reads go to the views.

The split lets each side do one job well. Aggregates stay small, holding only what their rules need. Views can be as numerous and as specialized as the screens that use them, and adding one never touches a rule. A new question about past activity can be answered by building a new view from the existing events.

A view, also called a read model or projection, is the query side’s unit. It reads events, folds them into state, and keeps that state current as new events arrive. The account summary below keeps an owner and a balance for every account.

src/AccountSummary.view.ts
export class AccountSummary extends Domain.View("AccountSummary", {
from: [Account],
key: Account,
state: Schema.Struct({ owner: Schema.NullOr(Schema.String), balance: Schema.Int }),
initial: { owner: null, balance: 0 },
evolve: {
AccountOpened: (v, e) => ({ ...v, owner: e.owner }),
MoneyDeposited: (v, e) => ({ ...v, balance: e.balanceAfter }),
MoneyWithdrawn: (v, e) => ({ ...v, balance: e.balanceAfter }),
},
}) {}

A client reads a view in one of two ways: fetch its current state once, or watch it for the state now and after every change.

packages/server/test/readSummary.ts
const summary = yield* bank.AccountSummary.get(accountId); // once
const changes = bank.AccountSummary.watch(accountId); // a stream of every change

Views are updated after events are recorded, not in the same step. A view is always correct, but it may be a moment behind the aggregate. That delay is called eventual consistency. A screen that has just sent a command and wants to show its effect waits until the view has caught up with it.

Not every read model belongs in the model’s own storage. A search index, a reporting database or a data warehouse is built the same way, by consuming events, but lives in a store you choose. That’s a projection: a read model the model feeds and you own. The bank’s ledger copies every money movement into a database table:

packages/domain/src/Ledger/Ledger.projection.ts
export class Ledger extends Domain.Projection("Ledger", {
from: [Account],
key: Account,
on: [
MoneyDeposited,
MoneyWithdrawn,
TransferDebited,
TransferCredited,
TransferRefunded,
SettlementReceived,
],
}) {}

See View and Projection.

Businesses have rules about what to do when something happens. Whenever a customer registers, open their main account. Whenever an account’s withdrawals for a day pass a threshold, have it checked for fraud, and freeze it if the risk is high. These are business policies, and DDD models each one as a policy.

A policy is triggered by an event, decides whether and how to act, and acts by sending commands or calling systems outside the model. The fraud policy, for example, asks a scoring service and only freezes the account if the score is high.

CustomerCustomerRegisteredpolicyopen main accountAccountOpenAccount
AggregatePolicy
The registration is recorded first. The policy then sends OpenAccount to a different aggregate.

The main-account policy is declared with what triggers it, and implemented as a Layer that sends the command:

src/MainAccountProvisioning.policy.ts
export class MainAccountProvisioning extends Domain.Policy("MainAccountProvisioning", {
from: Customer,
on: [CustomerRegistered],
}) {}
export const MainAccountProvisioningLive = MainAccountProvisioning.toLayer(
Effect.gen(function* () {
const accounts = yield* Account;
return Effect.fn(function* ({ source }) {
yield* accounts
.send(AccountId.make(`${source.id}-main`), new OpenAccount({ owner: source.id }))
.pipe(Effect.catchTag("AlreadyOpen", () => Effect.void));
});
}),
);

Policies and aggregates both apply rules, but different kinds. An aggregate enforces invariants: rules that must hold the instant a command commits, inside one boundary, and that can refuse the command. A policy applies a procedure: what the business does next, often across aggregates or outside the model. It runs after the event, and it can’t refuse what already happened. It can only decide what happens next, through commands that the receiving aggregates can still refuse.

When a process takes several steps across aggregates and can fail partway, like a transfer that debits one account and then credits another, a saga drives it step by step, and undoes earlier steps if a later one fails. A policy usually starts it. See Saga.

Designing this way makes the model easy to test, because every piece is defined by events. An aggregate’s behaviour is a function of its history and a command. A view’s state is a function of the events it has read. A policy’s actions are a function of the event that triggered it.

So a test needs no database, no mocks and no setup code. It states the history as events, sends one command, and checks the outcome as events, or as a rejection:

givenevents so farwhena commandthennew eventsor rejecteda rejection
CommandEventRejection

That’s also how the business states its rules: “an account holding 100 refuses a withdrawal of 150”. The test reads the same way:

test/Account.test.ts
it.effect("refuses to withdraw more than the balance", () =>
story(
Story.given(
account,
new AccountOpened({ owner: "sam" }),
new MoneyDeposited({ amount: 100, balanceAfter: 100 }),
),
Story.when(account, new WithdrawMoney({ amount: 150 })),
Story.rejected(new InsufficientFunds({ balance: 100, requested: 150 })),
),
);

The same shape covers the rest of the model. A view is tested by giving it events and checking its state. A policy is tested by checking the commands it sends, and calls to external systems wait for the test to answer them, so every outcome of an outside call can be tried. Because the whole model runs in memory, a test of a process spanning several aggregates still runs in milliseconds.

See Story.

Large businesses don’t have a single language. To the bank, an “account” holds money. To the support team’s ticketing system, an “account” is a login. One model that tries to serve both ends up serving neither.

DDD splits the business into bounded contexts: areas in which one model and one language apply consistently. Each has its own aggregates, views and policies. Contexts talk to each other through published interfaces, and never by reaching into each other’s aggregates.

AccountBanking: holds moneyBankApipublished interfaceAccountIdentity: a login
AggregateApi
Two bounded contexts, each with its own meaning of Account, talking through an Api.

Each concept on this page is a building block in alchemy/Domain. A bounded context is a Domain.Model, and its published interface is an Api. Inside it, you declare commands, events, aggregates with decide and evolve, views and policies. The platform you run the model on provides the guarantees described here: one command at a time per aggregate, events and state committed together, and every event delivered to every view and policy that consumes it, in order.

A model lists its building blocks:

src/Bank.model.ts
export class Bank extends Domain.Model("Bank", {
aggregates: [Account, Customer],
views: [AccountSummary, Statement],
policies: [MainAccountProvisioning],
}) {}

Next, build one: the tutorial puts these ideas to work on a small bank, one step at a time. Or see how a running model processes every command in Domain Model.