Skip to content

Domain Model

A Domain.Model is one bounded context running as a whole. It holds the context’s aggregates, the views built from their events, and the policies that act on them, and it guarantees how commands and events move between them.

The concept pages that follow read as a manual, one building block per chapter, in the order a command meets them. This first chapter is the map. It describes what every model contains and what happens to every command, with the bank from the tutorial as the example throughout, then shows how to declare, run and call a model.

If the words aggregate, view and policy are new, read What is Domain-Driven Design? first.

A business is too big for one model. The words its people use change meaning from one department to the next: to the bank’s tellers, an “account” holds money; to its identity team, an “account” is a login. A single model that tries to serve both meanings ends up with a vague Account that serves neither.

So DDD draws boundaries. Inside a bounded context, every word means exactly one thing, and the model is written in those words. In the example bank, the banking context knows accounts, deposits, withdrawals and transfers. It doesn’t know about login sessions, password resets or support tickets; those belong to other contexts, with models of their own.

A good boundary passes two tests. Every name inside it has one meaning, so a conversation with the business turns straight into code. And the rules inside it can be enforced without asking another context’s permission, so the model can decide on its own. When a word starts needing a qualifier, like “billing account” versus “login account”, you’ve found the edge of a context.

A Domain.Model is how you write one bounded context down. Everything in this manual lives inside one.

Every model has the same shape. Commands enter on the left and are decided by aggregates. Aggregates record events. Events flow to three kinds of consumer: views, which the model stores and serves; projections, which write a store you own or a stream; and policies, which send further commands.

CommandsrequestsAggregatesdecideEventsthe recordViewsanswer readsProjectionswrite storesPoliciesact next
CommandAggregateEventViewProjectionPolicy
Solid edges happen within one command. Dashed edges are delivered after it commits.

A model declares which aggregates, views, policies, projections, streams and queries it contains. The commands and events come with them, since each aggregate declares the commands it handles and the events it records. The example bank’s model contains two aggregates, two views and one policy:

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

An aggregate type has many instances, one per id, and every command names the id of the instance it’s for. The model routes the command to that instance and no other.

Each instance handles one command at a time. Commands for the same instance queue, and each is decided against the state the previous one left. Commands for different instances run independently. This is what makes an instance a consistency boundary: nothing else changes its state while it decides.

commandfor id 1commandfor id 1commandfor id 2instance 1one at a timeinstance 2one at a time
CommandAggregate

In the bank, a deposit names the account it’s for, so two deposits into a-1 queue while a deposit into a-2 runs alongside them:

src/depositAndRead.ts
const receipt = yield* accounts.send(accountId, new DepositMoney({ amount: 100 }));

The aggregate decides, and everything commits at once

Section titled “The aggregate decides, and everything commits at once”

The instance runs the aggregate’s decide handler for the command against its current state. A rejection ends the command: nothing is recorded, and the caller gets the rejection as a typed error. Otherwise, decide returns events, and each is applied to the state with evolve.

Then the instance commits, in one atomic write: the events, appended to its stream with sequence numbers; its new state and version; the reply; and a delivery for every view and policy that consumes the events. Either all of it is stored or none of it is, so a crash can never leave events without their deliveries, or state that disagrees with its events.

decideacceptedone atomic writeeventsappendedstatenew versionreceiptwith the replydeliveriesviews, policies
Event

The caller gets a receipt with the stream and version the command committed at, the events, and the reply. The receipt is also stored under the command’s id. If the same command arrives again, after a retry over a flaky connection, the instance returns the stored receipt instead of deciding twice.

For a deposit into an open account, decide returns one MoneyDeposited event carrying the new balance:

src/Account.aggregate.ts
decide: {
DepositMoney: (account, cmd) =>
account._tag !== "Open"
? new NotOpen()
: [new MoneyDeposited({ amount: cmd.amount, balanceAfter: account.balance + cmd.amount })],
},

After the commit, the deliveries are sent. Every view, projection and policy receives every event it consumes, at least once, and in the order its source instance recorded them, even if something restarts in between. Views and projections drop events they’ve already accepted; policies make repeats harmless (see Policy).

Because this happens after the commit, views are eventually consistent: for a moment, the aggregate has the new state and a view doesn’t yet. A reader who needs to see their own change passes the command’s receipt, and the view waits until it has applied that version.

In the bank, one MoneyDeposited reaches both views that read accounts:

MoneyDepositedAccount a-1AccountSummarybalanceStatementa new line
EventView

And a client that has just deposited reads the summary at that receipt, so it sees the new balance:

src/depositAndRead.ts
return yield* summaries.get(Account.ref(accountId), { atLeast: receipt });

Add a view or a projection to a model that already has events, and it receives those events first. You deploy, and it builds itself from the history, with no command and no script to run.

Each key of the new consumer pulls the history from every instance that feeds it and applies those events in the order they were recorded, by timestamp. A summary per account sees its account’s events in order, and a single dashboard fed by every account sees all of them in order, as if it had been there from the start. Events recorded while it catches up wait their turn, then arrive live.

Policies never receive history. They act on new events only, so adding a policy never acts on the past again.

A view’s state is derived, so it can always be computed again. Whenever you change what a view computes (its evolve, emit, keyOf, state, or the events and sources it reads), bump its version:

src/AccountSummary.view.ts
import * as Domain from "@alchemy.run/domain";
import * as Schema from "effect/Schema";
import { Account } from "./Account.aggregate.ts";
export class AccountSummary extends Domain.View("AccountSummary", {
from: [Account],
key: Account,
state: Schema.Struct({ net: Schema.Number }),
initial: { net: 0 },
evolve: {
MoneyDeposited: (v, e) => ({ net: v.net + e.amount }),
MoneyWithdrawn: (v, e) => ({ net: v.net - e.amount }),
},
version: 1,
}) {}

On the next deploy every key is computed again from the history, with the new code, and so is every view and projection that reads this one. You don’t choose how: the deploy picks the cheapest way that is correct.

A view keyed by the one aggregate it reads, like this summary per account, is rebuilt in place. It keeps its storage, and the deploy waits for nothing. The first time a key is touched after the deploy, it reads its own account’s events again before it answers, so even events it never received before (here, withdrawals) count. A view or projection that reads it gets each key’s new rows, never a key still being rebuilt. On Cloudflare, an object busy during the deploy keeps its old code until it is idle; until then the Worker computes that key’s answers itself, from its account’s events.

Any other view, such as one fed by many accounts through keyOf, is rebuilt from the history: it starts over in new storage and receives every event again, and the deploy waits for it, the same way it does for a new view.

Only ever increase a version. To undo a change, change the code back and bump again. Bump it too when you add back a view you removed earlier: its stored state stopped following the history while it was gone.

A view keyed by the one aggregate it reads can skip the backfill when it is added, too. List it as lazy on the model’s Layer:

src/Bank.live.ts
import * as Layer from "effect/Layer";
import { Bank } from "./Bank.model.ts";
export const BankLive = Bank.layer(Layer.empty, { lazy: ["AccountSummary"] });

A deploy that adds it waits for nothing. Each key reads its own account’s history the first time it’s read, or when that account next changes, and every answer it gives covers the whole history. Keys nobody reads are never built.

A view fed by many instances, like a total across every account, can’t be lazy: one key doesn’t know which instances feed it. The model fails to build if you try.

Until a new or rebuilt view has received the history, its answers cover only part of it. history says whether it’s complete:

src/summaryStatus.ts
const { complete } = yield* summaries.history;

A screen can show “catching up” until complete is true, or hold its reads until then. A projection’s Progress service has the same history.

On Cloudflare the deploy itself waits until the history is complete. See History at scale for how long that takes and what it costs.

Every consumer works the same way underneath. At commit, each event is routed to a key of each consumer that wants it: by default its source instance, or whatever the consumer’s key and keyOf say. Each key has an ordered input log, and its inputs are handled one at a time, or a batch at a time for a projection. Keys run in parallel.

That gives a precise guarantee. Within a key, events from one source arrive in that source’s order. Between keys, and between different sources feeding one key, there is no order: an account can be a day ahead of the customer that owns it. The model is distributed on purpose, so design consumers to correlate by id, use the envelope’s at for business time, and let an aggregate decide whenever the order between two facts matters.

All three consumers receive the same events. They differ in what they do with them.

A view folds events into state that the model stores and serves: get, watch, events, with a validate on its Layer and read-your-own-write. Reach for it first when clients need to read something.

A projection writes events into a store you own, such as a database or a search index, or into a stream the model serves. It never sends commands, so it can always be replayed.

A policy is a business rule. It acts on each new event once, through commands and external systems, and never replays.

A query is not a consumer: it serves what a projection wrote to clients, across keys, guarded by middleware and rejections like the model’s other reads, with a handler that checks the caller.

A model lists what runs together. It can start as a single aggregate.

src/Bank.model.ts
import * as Domain from "@alchemy.run/domain";
import { Account } from "./Account.aggregate.ts";
export class Bank extends Domain.Model("Bank", {
aggregates: [Account],
}) {}

Adding a view to views is enough for the model to deliver the events it reads. You never wire an event to a view by hand.

src/Bank.model.ts
import * as Domain from "@alchemy.run/domain";
import { Account } from "./Account.aggregate.ts";
import { AccountSummary } from "./AccountSummary.view.ts";
export class Bank extends Domain.Model("Bank", {
aggregates: [Account],
views: [AccountSummary],
}) {}

A policy is declared in one place and implemented in another, because its implementation usually needs things from outside the model, like a client for another aggregate or a payment provider. The declaration goes in policies; the implementation is a Layer you keep beside it.

src/Bank.model.ts
import * as Domain from "@alchemy.run/domain";
import { Account } from "./Account.aggregate.ts";
import { AccountSummary } from "./AccountSummary.view.ts";
import { Customer } from "./Customer.aggregate.ts";
import {
MainAccountProvisioning,
MainAccountProvisioningLive,
} from "./MainAccountProvisioning.policy.ts";
export class Bank extends Domain.Model("Bank", {
aggregates: [Account, Customer],
views: [AccountSummary],
policies: [MainAccountProvisioning],
}) {}
export const BankHandlers = MainAccountProvisioningLive;

A model’s last argument is its annotations. The model lists its declarations, so tooling can walk them and read each one’s annotations. See Build on them.

src/Bank.model.ts
import * as Domain from "@alchemy.run/domain";
import { Account } from "./Account.aggregate.ts";
export class Bank extends Domain.Model(
"Bank",
{ aggregates: [Account] },
{ description: "Accounts, transfers and the ledger of a small bank." },
) {}

Some mistakes in a declaration only show when the model is hosted: a view that reads a source keyed by another aggregate without keyOf, a view with state but no initial, a policy that waits for an event no source emits, a stream with two writers. Building the model’s Layer fails on the first one. Domain.verify finds all of them without hosting anything, so one unit test covers the whole model:

packages/domain/test/Bank.model.test.ts
import * as Domain from "@alchemy.run/domain";
import { expect, it } from "@effect/vitest";
import { Bank } from "../src/Bank.model.ts";
it("the model is well defined", () => {
expect(Domain.verify(Bank)).toEqual([]);
});

Each problem is a Domain.DefinitionError. Its reason is a tagged error that says which problem it is, such as MissingKeyOf or TwoWriters, with the names involved as fields, and its message names the block and the fix. Mistakes that depend on what a handler returns, like decide returning an event its command doesn’t declare, still fail when the handler runs.

A declaration doesn’t run anything. Bank.layer(BankHandlers) turns it into a Layer that hosts the model, and a platform underneath decides where its state lives.

src/BankInMemory.ts
import * as Domain from "@alchemy.run/domain";
import * as Layer from "effect/Layer";
import { Bank, BankHandlers } from "./Bank.model.ts";
export const BankInMemory = Bank.layer(BankHandlers).pipe(Layer.provide(Domain.InMemory));

Domain.InMemory keeps everything in memory, which is what tests want. Swap it for another platform and the model runs there unchanged; every platform keeps the guarantees above. See Hosting.

Inside the hosted model, each aggregate and view is a service. yield* Account gives you a client for every account, and send takes the target id and the command.

src/depositAndRead.ts
import * as Effect from "effect/Effect";
import { Account } from "./Account.aggregate.ts";
import { AccountId } from "./Account.id.ts";
import { AccountSummary } from "./AccountSummary.view.ts";
import { DepositMoney } from "./DepositMoney.command.ts";
export const depositAndRead = Effect.gen(function* () {
const accounts = yield* Account;
const summaries = yield* AccountSummary;
const accountId = AccountId.make("a-1");
const receipt = yield* accounts.send(accountId, new DepositMoney({ amount: 100 }));
return yield* summaries.get(Account.ref(accountId), { atLeast: receipt });
});

Passing the receipt as atLeast is the read-your-own-write from earlier: get waits until the summary has applied the deposit.

Clients outside the model, a browser or another service, reach it through an Api instead, which decides what each kind of client may send and read.

A model is one bounded context, so every name in it means one thing. When two parts of the business use a word differently, give each its own model. One model never sends commands to another model’s aggregates directly; it calls that model’s Api.

That rule keeps each model free to change. A support tool built on another model’s Api depends on the commands and reads that model chose to publish, not on how its aggregates store their state today. The model can split an aggregate, rename a state or add a view without the support tool noticing.

SupportDomain.ModelBankApiDomain.ApiBankDomain.Model
Api

Each model owns its data. Two models can use the same aggregate definition, and each still gets its own instances:

class Billing extends Domain.Model("Billing", { aggregates: [Account], views: [Ledger] }) {}
class Audit extends Domain.Model("Audit", { aggregates: [Account], views: [Owners] }) {}

Account a-1 in Billing and account a-1 in Audit are different instances, with their own events. Ledger only hears Billing’s accounts, and Owners only hears Audit’s. Every platform keeps them apart, even on the same Worker; see Cloudflare: Host several models in one Worker. Give every model its own name: hosting two models with the same name on one platform fails.

The chapters that follow take each building block in turn, in the order a command meets them.

A Command asks for a change, and an Event records one. An Aggregate sits between them, deciding each command against the rules it protects. On the query side, a View answers a question from events, a Projection feeds a store you own, and a Stream keeps an ordered log a client can follow. A Policy applies the business’s standing rules about what to do next, and a Saga carries out a process that takes several steps. An External System is how the model talks to the world outside it, and an Api is what the model publishes to its clients. Finally, a Story states each rule as an example you can run.

Next, Command: how a change is asked for.