Aggregate
An aggregate is the part of a model that makes decisions. Each instance, one bank account for example, holds the state its rules need, takes one command at a time, and answers with events or a refusal.
What an aggregate protects
Section titled “What an aggregate protects”Every business has rules that must never be broken, not even for a moment. An account’s balance never goes below zero. A username belongs to at most one customer. A transfer is either approved or expired, never both. DDD calls these invariants, and an aggregate is the thing that keeps one true.
To find the invariants, ask the business what must never happen. Then ask which commands could make it happen. A withdrawal could overdraw an account, so WithdrawMoney has to be decided by something that knows the balance. That something is the Account aggregate: it holds the balance, and every command that could break the rule about it goes through it.
So an aggregate isn’t a table or an entity with getters and setters. It’s a rule, together with exactly the state the rule needs, and the commands that have to respect it. Find the rule first; the state and the commands follow.
Give it an identity
Section titled “Give it an identity”An aggregate type has many instances, and each is addressed by an id. The account’s id is an AccountId, the same schema its commands use as their target.
export class Account extends Domain.Aggregate("Account", { id: AccountId,Instances aren’t created explicitly. Every id exists from the start, in the aggregate’s initial state, and a command sent to a new id is decided against that state. Opening an account is just the first command an account accepts.
Model the state its rules need
Section titled “Model the state its rules need”The state holds what decide needs to check its rules, and nothing more. An account must know whether it’s open, who owns it, and its balance.
const State = Schema.Union([ Schema.TaggedStruct("Unopened", {}), Schema.TaggedStruct("Open", { owner: Schema.String, balance: Schema.Int }),]);A union of tagged states makes the lifecycle explicit. An Unopened account has no owner and no balance, so there’s no field to forget to check. Code that wants the balance has to establish that the account is Open first, and the types enforce it.
state: State,initial: { _tag: "Unopened" },initial is the state of every instance before its first event.
Keep the statement, the transaction count and the monthly totals out of here. The account doesn’t need them to decide anything, so they belong in views.
Decide each command
Section titled “Decide each command”decide has one handler per command in commands. A handler receives the current state and the command, and returns the events to record or a rejection.
decide: { WithdrawMoney: (account, cmd) => account._tag !== "Open" ? new NotOpen() : cmd.amount > account.balance ? new InsufficientFunds({ balance: account.balance, requested: cmd.amount }) : [new MoneyWithdrawn({ amount: cmd.amount, balanceAfter: account.balance - cmd.amount })],},Read it as the business rule it is: an account that isn’t open can’t be withdrawn from; a withdrawal can’t exceed the balance; otherwise, the money is withdrawn and here’s the new balance.
decide doesn’t change the state. It only describes what should happen. That keeps every rule in a function you can test with a state and a command, and nothing else.
A handler can also return an empty array. The command is accepted, but there’s nothing to record, like freezing an account that’s already frozen. The caller still gets a receipt.
So every handler picks one of three answers. Record events when the command changes something. Return [] when the state already is what the command asks for. Return a rejection when the command can’t be done, so the caller sees why in its type.
Name the rules decide shares
Section titled “Name the rules decide shares”Most commands check the same thing first: the account must be open. Write that check once, as a pure function that returns the narrowed state or the rejection.
type Open = Extract<State, { _tag: "Open" }>;
const openFor = (s: State): Open | NotOpen => (s._tag === "Open" ? s : new NotOpen());Each handler starts from it. After the check, o is an Open account, so the rest of the rule reads its balance without another test.
decide: { DepositMoney: (account, cmd) => { const o = openFor(account); if (o._tag !== "Open") return o; return [new MoneyDeposited({ amount: cmd.amount, balanceAfter: o.balance + cmd.amount })]; },},A handler written with Effect.fn fails with the rejection instead: if (o._tag !== "Open") return yield* o;.
Record the facts evolve needs
Section titled “Record the facts evolve needs”An event carries the outcome decide worked out, not just the request. MoneyDeposited records balanceAfter, so evolve copies it and never repeats the arithmetic.
export class MoneyDeposited extends Domain.Event("MoneyDeposited", { amount: Schema.Int, balanceAfter: Schema.Int,}) {}The rules then live in one place. A view or a policy reading the event sees the same balance the account does.
Apply events with evolve
Section titled “Apply events with evolve”evolve has one handler per event. It receives the current state and the event, and returns the next state.
evolve: { AccountOpened: (_, e) => ({ _tag: "Open" as const, owner: e.owner, balance: 0 }), MoneyDeposited: (s, e) => (s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s), MoneyWithdrawn: (s, e) => (s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s),},Keeping evolve separate from decide is what lets the state be derived from events. decide runs once, when the command arrives, and may refuse. evolve must always accept, because it applies events that have already been recorded, and applying the same events must always give the same state.
evolve never fails either, and never throws. Refusing is decide’s job: every command that records MoneyDeposited has already returned NotOpen for an account that isn’t open. So each handler covers every state, and keeps a state its event can’t follow. An event that moves the account to another stage, like AccountOpened, returns the new state whole.
Reply from the new state
Section titled “Reply from the new state”A command that declares a reply gets one from a reply handler. It runs after evolve, on the state the events produced.
reply: { DepositMoney: (s) => ({ balance: s._tag === "Open" ? s.balance : 0 }), WithdrawMoney: (s) => ({ balance: s._tag === "Open" ? s.balance : 0 }),},Like evolve, reply covers every state and never fails. The reply is stored with the events, so a retried command returns the same reply as the first time.
Focus on a state with optics
Section titled “Focus on a state with optics”Once many handlers repeat the same s._tag === "Open" ? ... : s, an optic from effect/Optic can say it once. It focuses on the Open state, and leaves any other state unchanged.
import * as Optic from "effect/Optic";
const open = Optic.id<State>().tag("Open");const balance = open.key("balance");A handler that sets one field replaces it through the optic.
MoneyDeposited: (s, e) => Optic.replace(s, balance, e.balanceAfter),A handler that changes several fields modifies the focused state.
OwnerChanged: (s, e) => open.modify((o) => ({ ...o, owner: e.owner }))(s),Optics are optional. They give the same states as the plain checks, with less repetition. The example bank uses them for its account.
Never refuse a compensation
Section titled “Never refuse a compensation”Some commands undo a step another process already took, like refunding a transfer whose credit failed. Refusing one would strand the money, so its handler records a fact for every state instead of rejecting.
decide: { RefundTransfer: (account, cmd) => account._tag === "Open" ? [ new TransferRefunded({ transferId: cmd.transferId, amount: cmd.amount, balanceAfter: account.balance + cmd.amount, }), ] : [new RefundStranded({ transferId: cmd.transferId, amount: cmd.amount })],},RefundStranded tells a policy or a person that the account closed before the money came back. The saga that sent the refund never has to handle a refusal it can do nothing about.
Use context in decisions
Section titled “Use context in decisions”Some rules need more than the state and the command. Only the account’s owner may withdraw from it, so decide needs to know who’s asking.
A command’s middleware can provide that. When it does, the handler can be an Effect.fn and read it. In the example bank, the withdrawal handler first checks that the current actor may act on the account.
const authorized = Effect.fn(function* (account: AccountState) { const o = openFor(account, yield* CurrentActor); if (o._tag !== "Open") return yield* o; return o;});Rules can depend on time, too. The handler’s third argument carries now, the time the command is decided. The example bank uses it for a daily withdrawal limit:
const used = o.today.day === dayOf(now) ? o.today.withdrawn : 0;if (used + cmd.amount > DAILY_LIMIT) return yield* new DailyLimitExceeded({ remaining: DAILY_LIMIT - used });now is the time the command is stamped with, and its events record the same time as at in their envelopes, which is what evolve uses to track the day’s total. A story can move the clock, so rules like this are testable.
One command at a time
Section titled “One command at a time”Each instance processes one command at a time. When two withdrawals for the same account arrive together, the second is decided against the state the first one left. Both can’t pass the balance check on the same money.
Everything a command produces, its events, the new state, the reply and the deliveries to views and policies, commits together or not at all. This is what makes an instance a consistency boundary: its rules hold at every moment, for every observer.
Nothing spans two instances. A transfer between accounts can’t debit one and credit the other in a single step, because they’re separate boundaries. Instead, each account handles its own side, and a saga drives the steps in order.
That shapes how big an aggregate should be. Draw it around the rules that must hold together, and no wider. A customer and their accounts are separate aggregates: no rule needs a customer’s name and an account’s balance to change in the same step, and keeping them apart means one busy account never slows down the others.
Give a rule across instances its own aggregate
Section titled “Give a rule across instances its own aggregate”Some rules span many instances. Usernames must be unique across every customer, but each customer is its own aggregate, and no customer can see the others. Making Customer check every other customer would need a transaction across all of them, which doesn’t exist.
The answer is to ask what the rule is about. Uniqueness is about one username, so make the username the aggregate. UsernameClaim is keyed by the username itself, and holds who has it:
export class UsernameClaim extends Domain.Aggregate("UsernameClaim", { id: Username, state: Schema.Union([ Schema.TaggedStruct("Free", {}), Schema.TaggedStruct("Claimed", { customerId: CustomerId }), ]), initial: { _tag: "Free" }, commands: [ClaimUsername], decide: { ClaimUsername: (usernameClaim, cmd) => usernameClaim._tag === "Free" ? [new UsernameClaimed({ customerId: cmd.customerId })] : usernameClaim.customerId === cmd.customerId ? [] : new UsernameTaken(), }, evolve: { UsernameClaimed: (_, e) => ({ _tag: "Claimed" as const, customerId: e.customerId }), },}) {}Two customers who ask for sam at the same moment both send ClaimUsername to the same instance, UsernameClaim/sam. It decides one at a time, so one wins and the other is told UsernameTaken. The rule holds without anything spanning two aggregates. See Unique values.
Keep a process’s state in an aggregate
Section titled “Keep a process’s state in an aggregate”A process that takes time, like a transfer that may wait a day for approval, has state of its own: requested, awaiting approval, completed, failed. Rules apply to that state too. An approval that arrives after the deadline must be refused, and an expiry that arrives after an approval must do nothing.
Those rules make the process an aggregate. In the example bank, Transfer holds where each transfer stands, and both ApproveTransfer and ExpireTransfer go through it, so whichever arrives first wins:
ExpireTransfer: (s, _, { id }) => s._tag === "AwaitingApproval" ? [new TransferFailed({ transferId: id, reason: "not approved in time" })] : [],The money itself still moves through the two accounts, each guarding its own balance. A saga carries out the steps, and reports each outcome back to the Transfer, so there’s one place that knows how every transfer ended.
Describe it
Section titled “Describe it”An aggregate’s last argument is its annotations. When an Api lets clients read the aggregate, its read tools, CLI subcommand and OpenAPI operations show the description after the sentence that says what the call does.
export class UsernameClaim extends Domain.Aggregate( "UsernameClaim", { id: Username, state: Schema.Union([ Schema.TaggedStruct("Free", {}), Schema.TaggedStruct("Claimed", { customerId: CustomerId }), ]), initial: { _tag: "Free" }, commands: [ClaimUsername], decide: { ClaimUsername: (usernameClaim, cmd) => usernameClaim._tag === "Free" ? [new UsernameClaimed({ customerId: cmd.customerId })] : usernameClaim.customerId === cmd.customerId ? [] : new UsernameTaken(), }, evolve: { UsernameClaimed: (_, e) => ({ _tag: "Claimed" as const, customerId: e.customerId }), }, }, { description: "Who holds a username. Each username has at most one holder." },) {}Send it commands
Section titled “Send it commands”Inside a hosted model, the aggregate is a service. yield* Account gives you its client, and send takes an instance id and a command.
yield* accounts .send(AccountId.make(`${source.id}-main`), new OpenAccount({ owner: source.id })) .pipe(Effect.catchTag("AlreadyOpen", () => Effect.void));The same client reads an instance’s current state, as of its latest commit, with accounts.get(id), schedules a command for later with accounts.schedule(id, command, { after }), which returns the command id and when it’s due, and cancels a pending one with accounts.unschedule(id, commandId). Scheduling again under the same command id replaces the earlier command, even one that already ran. unschedule fails with AlreadyExecuted, AlreadyCancelled or NotScheduled when there’s nothing pending to cancel. See Deadlines.
Clients outside the model send commands through an Api. They never read an aggregate’s state directly: a view projects what they may see of it, so the aggregate’s state stays free to change with its rules.
Next, View: the query side, built from the events aggregates record.