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. This page describes that in general, 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.
What a model contains
Section titled “What a model contains”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.
A model declares which aggregates, views, policies, projections and streams it contains. The commands and events come with them, since each aggregate declares the commands it handles and the events it records. The bank’s model contains two aggregates, two views and one policy:
export class Bank extends Domain.Model("Bank", { aggregates: [Account, Customer], views: [AccountSummary, Statement], policies: [MainAccountProvisioning],}) {}Each command goes to one instance
Section titled “Each command goes to one instance”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.
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:
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.
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:
DepositMoney: (s, cmd) => s._tag !== "Open" ? new NotOpen() : [new MoneyDeposited({ amount: cmd.amount, balanceAfter: s.balance + cmd.amount })],Consumers catch up
Section titled “Consumers catch up”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:
And a client that has just deposited reads the summary at that receipt, so it sees the new balance:
return yield* summaries.query(Account.ref(accountId), { atLeast: receipt });Order and partitions
Section titled “Order and partitions”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.
Choose a consumer
Section titled “Choose a consumer”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 validate 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.
Declare the model
Section titled “Declare the model”A model lists what runs together. It can start as a single aggregate.
import * as Domain from "alchemy/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.
import * as Domain from "alchemy/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.
import * as Domain from "alchemy/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;Run it
Section titled “Run it”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.
import * as Domain from "alchemy/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.
Call it from your code
Section titled “Call it from your code”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.
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.query(Account.ref(accountId), { atLeast: receipt });});Passing the receipt as atLeast is the read-your-own-write from earlier: query 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.
Keep one language per model
Section titled “Keep one language per model”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.
The pages that follow take each building block in turn, in the order a command meets them: Command, Event, Aggregate, View, Policy and Api.