Skip to content

Command

A command asks one aggregate instance for a change. It’s a request, so it can be refused, and it’s named as an instruction: OpenAccount, DepositMoney, WithdrawMoney.

A command is also a contract. Before you send one, its declaration tells you which instance it goes to, what data it carries, which events it may record, which refusals it may return, and what you get back.

Commands come from the verbs people use when they describe their work. At a bank, for example, a customer opens an account, deposits money, withdraws cash, requests a transfer; a support agent freezes an account, approves a large transfer. Each of those sentences is a command, named the way the business says it.

Customerwho asksSupport agentwho asksWithdrawMoneya customer's intentFreezeAccountan agent's intentAccountdecides both
CommandAggregateClient
Each command is one thing someone wants, in the words they'd use to ask for it.

The opposite is a command named after the data it touches: UpdateAccount, SetBalance, PatchCustomer. Those say what changes in storage, but not why. An UpdateAccount that sets frozen: true and one that sets owner: "kim" are different requests with different rules: who may make them, when they’re refused, and what the history should say afterwards. Give each intent its own command, FreezeAccount and ChangeOwner, so each gets its own rules, its own refusals and its own event.

A useful test: read the command’s name aloud to someone who works in the business. If they’d say “yes, customers do that”, it’s a good command. If they’d ask “update it how?”, split it.

A command carries what the caller knows and wants. It doesn’t carry what the aggregate will work out. A withdrawal says how much to withdraw; it doesn’t say what the balance will be afterwards, because only the account knows its balance at the moment it decides.

new WithdrawMoney({ amount: 30 }); // the intent
new MoneyWithdrawn({ amount: 30, balanceAfter: 70 }); // the outcome, decided by the account

That split keeps every rule in the aggregate. A client can’t send a balance it made up, and two clients that both read a balance of 100 can’t both withdraw 80: the account decides each withdrawal against its own state, one at a time.

Here is a withdrawal from the bank in the tutorial.

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 },
}) {}

The first argument is the command’s name. It’s how the command is identified on the wire and in the aggregate’s handlers, so it must be unique within a model.

A command’s last argument is its annotations. Its description is what a person or an agent reads before sending it: the MCP tool’s description, the CLI subcommand’s help, and the operation’s description in the OpenAPI document.

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 },
},
{ description: "Take money out of an account. The amount is in cents." },
) {}

A command, the events it records and the rejections it returns are one unit: you read them together, and change them together. Put them in one file named after the command, with a .command.ts suffix, like WithdrawMoney.command.ts. See Project layout.

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

When more than one command uses something, it moves to a file of its own. Deposits and withdrawals both refuse an account that isn’t open, so NotOpen has its own file, NotOpen.rejection.ts, and both import it.

src/NotOpen.rejection.ts
export class NotOpen extends Domain.Rejection("NotOpen") {}

The aggregate’s file then holds only its state and how it decides and evolves, and imports each command from its file. The same goes for ids, like AccountId, which the commands and the aggregate share.

target is the schema of the id the command is addressed to. A withdrawal goes to one account, so its target is an AccountId.

src/Account.id.ts
export const AccountId = Schema.String.pipe(Schema.brand("AccountId"));

The brand makes the id its own type. A CustomerId is a string too, but passing one where an AccountId is expected is a type error, so a command can’t be sent to the wrong kind of instance.

The target must be the id of the aggregate that lists the command. If Account lists a command whose target is a CustomerId, or a plain Schema.String, that entry in Account’s commands is a type error that names the command.

The target isn’t part of the command’s data. You pass it alongside the command when you send it, and the model uses it to route the command to that one instance.

input lists the fields the caller provides, each as a schema. You create the command with them, and the aggregate reads them in decide.

src/Account.aggregate.ts
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 })],
},

The schemas also decode the command when it arrives from a client, so decide only ever sees well-formed input. A command with no input, like a request to close an account, leaves input out.

A command can end in one of two ways: the aggregate records events, or it refuses. events lists the events the command may record, and rejects lists the refusals it may return.

A refusal is a Domain.Rejection, a typed error that can carry data about why.

src/WithdrawMoney.command.ts
export class InsufficientFunds extends Domain.Rejection("InsufficientFunds", {
balance: Schema.Int,
requested: Schema.Int,
}) {}

Both lists are enforced by the types. The aggregate’s decide handler for WithdrawMoney can only return MoneyWithdrawn events or one of its two rejections. And whoever sends it gets exactly NotOpen | InsufficientFunds in the error channel, so they can handle each case by name.

A rejection means nothing happened. No event is recorded and the state is unchanged, so the caller can safely try again with different input.

Name a rejection after the business reason, the way you’d explain it to the customer: InsufficientFunds, NotOpen, DailyLimitExceeded. Give it the data the caller needs to do something about it. InsufficientFunds carries the balance and the amount requested, so an app can say “you have 50, you asked for 80” without a second read.

Domain.Rejection is Schema.TaggedError with its own type filled in, so its third argument takes the same annotations. An HTTP API reads httpApiStatus from it, as it does for any Effect error:

src/NotOwner.rejection.ts
export class NotOwner extends Domain.Rejection(
"NotOwner",
{},
{ httpApiStatus: 403, description: "Only the account's owner may do this." },
) {}

These are the rejection’s annotations too, so NotOwner.annotations.description reads the description back.

rejects takes any tagged error class, so a Schema.TaggedError of your own, or one of Effect’s HttpApiError classes, works as a rejection too:

class WithdrawMoney extends Domain.Command("WithdrawMoney", {
target: AccountId,
input: { amount: Cents },
events: [MoneyWithdrawn],
rejects: [InsufficientFunds, HttpApiError.Forbidden],
}) {}

Some commands don’t ask permission. They tell the model about something that already happened outside it: a card payment settled at the payment processor, a deadline passed. The model can’t refuse a fact, so these commands declare no rejects. Their aggregate records what the fact means for it instead, even when the news is bad.

packages/domain/src/Account/RecordSettlement.command.ts
export class RecordSettlement extends Domain.Command("RecordSettlement", {
target: AccountId,
input: { settlementId: Schema.String, amount: Cents },
events: [SettlementReceived, SettlementOnClosedAccount],
}) {}

Money that settles on a closed account is still recorded, as SettlementOnClosedAccount, and a policy refunds it. See External System.

reply is what the caller gets back after the events commit. Here, the balance after the withdrawal.

The aggregate computes it from its state after the events, in a reply handler named after the command.

src/Account.aggregate.ts
reply: {
DepositMoney: (s) => ({ balance: s._tag === "Open" ? s.balance : 0 }),
WithdrawMoney: (s) => ({ balance: s._tag === "Open" ? s.balance : 0 }),
},

A command without a reply still tells the caller where it committed. See the receipt below.

Before a command reaches its aggregate, it can pass through middleware. In the full example bank, a withdrawal first authenticates the caller with Session, then asks for a second factor with MfaChallenge.

packages/domain/src/Account/WithdrawMoney.command.ts
export class WithdrawMoney extends Domain.Command("WithdrawMoney", {
target: AccountId,
middleware: [Session, MfaChallenge],
input: { amount: Cents },
events: [MoneyWithdrawn],
rejects: [NotOpen, NotOwner, Frozen, InsufficientFunds, DailyLimitExceeded],
reply: { balance: Cents },
}) {}

Nothing from its input reaches a trace unless its decide annotates it; see Observability.

Middleware runs in order, and any of it can stop the command with its own error, like Unauthorized. What it provides, such as who the caller is, travels with the command to the aggregate, where decide reads it to authorize the change. See Sessions and middleware.

A client can run the same decide with Account.predict when it provides what the middleware would, from a client-side Layer. See Optimistic updates.

Inside a hosted model, you send a command through the aggregate’s client, with the target id first.

src/MainAccountProvisioning.policy.ts
yield* accounts
.send(AccountId.make(`${source.id}-main`), new OpenAccount({ owner: source.id }))
.pipe(Effect.catchTag("AlreadyOpen", () => Effect.void));

From a client of an Api, each command is a method that takes the target, the input, and optional call options: bank.WithdrawMoney(accountId, { amount: 2_000 }).

Either way, send succeeds once the events have committed, with a receipt. It holds the stream and the version the instance committed at, the events it recorded, and the reply.

Networks fail after a command commits but before its receipt arrives. A client that retries could withdraw twice.

Every command has an id to prevent that. The instance stores each receipt under its command’s id, and when the same id arrives again, it returns the stored receipt without deciding again. Pass your own commandId when you might retry:

packages/server/test/withdrawOnce.ts
const first = yield* bank.WithdrawMoney(accountId, { amount: 2_000 }, { commandId: requestId, ...mfa });
const retry = yield* bank.WithdrawMoney(accountId, { amount: 2_000 }, { commandId: requestId, ...mfa });
first.version === retry.version; // true

Both calls return the same receipt, and the money leaves the account once. Without a commandId, each send gets a fresh one.

Commands sent by a policy get ids derived from the event that caused them, so a policy that runs twice for the same event sends the same commands, and each takes effect once.

A command can also be scheduled to arrive at a later time, which is how deadlines work: “expire this transfer if nobody approves it within a day”. Only commands that can’t be refused can be scheduled, because there’s no caller waiting to handle a rejection. See Deadlines.

packages/domain/src/Transfer/TransferApprovalDeadline.policy.ts
yield* transfers.schedule(source, new ExpireTransfer(), { after: APPROVAL_WINDOW });

Scheduling again under the same command id replaces the earlier command, even one that already ran.

Next, Event: what a command records when it succeeds.