Skip to content

Saga

A saga runs a process that spans several aggregates. An aggregate changes one instance at a time, and no transaction spans two, so a process that touches several runs as a series of steps, each committed on its own. When a step fails after earlier ones succeeded, the saga undoes them with compensations.

A saga is an Effect workflow. Each step’s result is stored, so a run that’s interrupted resumes after its last finished step, and a run can wait hours or days for a signal or a timer. A policy usually decides when to start one.

In the example bank, a transfer is a saga: it debits one account, credits another, and refunds the debit if the credit fails.

RequestTransfercommandTransferrecords the requestTransferExecutionstarts the sagaExecuteTransferruns the stepsDebitTransfer1 · source accountCreditTransfer2 · destination
CommandAggregatePolicySaga
A policy decides when the transfer runs; the saga carries it out, one step at a time.

Moving money between two accounts touches two aggregates, and each is its own consistency boundary. There’s no step in which both change together. So a transfer can’t be “debit and credit, or neither”, the way a database transaction would make it.

Instead, it’s a sequence of steps, each committed on its own: debit the source, then credit the destination. In between, the money is in flight. It has left one account and not yet reached the other, and anyone reading both balances at that moment sees it. That’s not a bug to hide; it’s how money moves in real banks too, and the Transfer aggregate records that the transfer is pending so screens can show it.

When a later step fails, the earlier ones have already happened. Their events are facts and can’t be erased. So the saga undoes them the way a business would, by recording another fact that reverses the effect: a debited transfer whose credit is refused is refunded, and the account’s history shows the debit and the refund, one after the other.

TransferDebiteda-1 · −50CreditTransfera-2 is closedTransferRefundeda-1 · +50
EventRejection
A compensation doesn't rewrite history. It adds the step that puts things right.

These undo steps are called compensations. Each step that can be undone declares its compensation next to it, and a saga that fails runs them in reverse order. A compensation must not be refused, or the process would be stuck halfway, so the command it sends records a fact in every state. See Never refuse a compensation.

A policy is one reaction: an event arrives, the policy acts, and it’s done. A saga is a process with memory. It knows which step it’s on, waits between steps, and knows what to undo if something fails.

Reach for a saga when a process has more than one step, waits for something, a decision or a timer, or has to undo earlier steps on failure. A policy usually starts the saga, on the event that records the request.

A saga has a key, the input it starts with, and every way it can be refused. The key identifies one run: starting the saga again with the same transfer id returns the same run.

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
export class ExecuteTransfer extends Domain.Saga("ExecuteTransfer", {
key: TransferId,
input: {
from: AccountId,
to: AccountId,
amount: Cents,
requestedBy: Actor,
needsApproval: Schema.Boolean,
},
rejects: [NotOpen, NotOwner, Frozen, InsufficientFunds, DestinationClosed],
}) {}

A saga’s last argument is its annotations. Its description is for the people and the tools that read the model, as ExecuteTransfer.annotations.

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
export class ExecuteTransfer extends Domain.Saga(
"ExecuteTransfer",
{
key: TransferId,
input: {
from: AccountId,
to: AccountId,
amount: Cents,
requestedBy: Actor,
needsApproval: Schema.Boolean,
},
rejects: [NotOpen, NotOwner, Frozen, InsufficientFunds, DestinationClosed],
},
{ description: "Debits one account, credits another, and refunds the debit if the credit fails." },
) {}

Its Layer resolves the clients its steps use, then returns what each run does. Each step is an Activity: a named Effect whose result is stored. When the run replays, a finished step returns its stored result instead of running again.

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
yield* Activity.make({
name: "credit",
error: DestinationClosed,
execute: accounts
.send(to, new CreditTransfer({ transferId: key, amount }))
.pipe(Effect.catchTag("NotOpen", () => Effect.fail(new DestinationClosed()))),
});

A step declares the errors it can fail with, so a refusal survives being stored. Here the destination account’s NotOpen becomes the saga’s own DestinationClosed.

A step that runs again is safe. A step can run twice: when it’s retried, or when its run is cut short after the step’s command committed but before its result was stored. The commands a step sends get ids derived from the run and the step, so the second run sends the same command ids, and the aggregate applies each command once. Keep each step to one command, so each stored result matches one fact.

A step can carry a compensation: what to do if the run fails after the step succeeded. The debit’s compensation refunds it.

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
yield* Activity.make({
name: "debit",
error: Schema.Union([NotOpen, NotOwner, Frozen, InsufficientFunds]),
execute: accounts.send(from, new DebitTransfer({ transferId: key, amount, requestedBy })),
}).pipe(
ExecuteTransfer.withCompensation(() =>
Activity.make({
name: "refund",
execute: accounts.send(from, new RefundTransfer({ transferId: key, amount })),
}),
),
);

When the credit is refused, the run fails, and every compensation registered so far runs, last step first.

DebitTransfersucceededCreditTransferrefusedDestinationClosedthe saga failsRefundTransferundoes the debit
CommandRejection

Debit before you credit. If the credit then fails, the money is refunded, so it can be in flight but never created out of nothing. In the same way, registration claims the username before the customer counts as registered: see Unique values.

The Transfer aggregate shows where every transfer stands, so the saga reports back to it. The last step completes the transfer:

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
yield* Activity.make({
name: "complete",
execute: transfers.send(key, new CompleteTransfer()),
});

A failure is recorded by a finalizer. It’s registered first, so it runs after every compensation:

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
yield* Workflow.addFinalizer((exit) =>
Exit.isSuccess(exit)
? Effect.void
: Activity.make({
name: "record-failure",
execute: transfers.send(key, new FailTransfer({ reason: reasonOf(exit.cause) })),
}).pipe(Effect.orDie),
);

Start a saga from a policy, on the event that records the request. The command that asked for the transfer commits TransferRequested and replies straight away; the policy starts the saga after that, and the saga runs on its own. Nobody waits for it.

RequestTransferreplies at onceTransferrecords the requestTransferRequestedthe factTransferExecutionstarts it, laterExecuteTransferruns on its own
CommandAggregateEventPolicySaga
The request is a fact before the process starts, so it's never lost, and the caller never waits for the process.

yield* ExecuteTransfer gives the saga’s client. start begins a run, or returns the one already started with that key, as a handle to the run. Policies run at least once, so key the saga by the aggregate it’s about: a policy that runs twice still starts one transfer.

packages/domain/src/Transfer/TransferExecution.policy.ts
case "TransferRequested":
yield* executions.start(source, {
from: event.from,
to: event.to,
amount: event.amount,
requestedBy: event.requestedBy,
needsApproval: event.needsApproval,
});
return;

List the saga on the model, next to its aggregates and policies, and its Layer with the other handlers.

packages/domain/src/Bank.model.ts
sagas: [ExecuteTransfer],

start returns as soon as the run is recorded. Its run handle also has result, which waits for the run to finish, but a run can take days, and a policy that waits holds up every later event on its key. Use result in tests and scripts. Anything that needs to react to how a saga ends should react to the events it reports instead, like TransferCompleted or TransferFailed.

A run can wait as long as it needs to. A large transfer waits until the Transfer decides it: an agent approves it, or its approval window closes first. The saga waits on a DurableDeferred, and suspends until someone completes it.

packages/domain/src/Transfer/ExecuteTransfer.saga.ts
if (needsApproval) {
// The Transfer decides between approval and expiry; nothing to do if it expired.
if ((yield* DurableDeferred.await(Decision)) === "expired") return;
}

The policy passes on the decision with the run’s signal. get returns the run with a key:

packages/domain/src/Transfer/TransferExecution.policy.ts
case "TransferApproved":
return yield* executions.get(source).signal(Decision, "approved");

The Transfer decides the race between approval and expiry, so there’s one source of truth for it. See Deadlines.

TransferStatus is a view of the transfer and both accounts. A client watches it to see the saga finish: the saga reports every step to the Transfer, so the view has everything a client should see.

packages/server/test/followTransfer.ts
export const followTransfer = Effect.fn(function* (bank: CustomerClient, transferId: string) {
yield* bank.TransferStatus.watch(TransferId.make(transferId)).pipe(
Stream.map(({ state }) => state),
Stream.takeUntil((status) => status?.completedAt !== null || status?.failedReason !== null),
Stream.runForEach((status) =>
Effect.log(status === null ? "pending" : TransferStatus.computed.status(status)),
),
);
});

The saga’s own client reads a run too, which suits operators and tests more than apps. get returns the run with a key:

const run = executions.get(transferId);
const status = yield* run.status;

status is NotStarted, Running, Completed with its result, Rejected with its rejection, Interrupted, or Died with a defect. Runs read the same as Effect workflow runs, on every engine.

A story runs the whole saga in memory: the request, every step, the compensations, and its status at the end.

Test what a saga does, not how it’s built: the commands its steps send show up as events on the aggregates, and Story.saga checks how the run ended, with the same status its run handle reads.

packages/domain/test/transfers.test.ts
Story.saga(ExecuteTransfer, transfer, { _tag: "Rejected", rejection: new DestinationClosed() }),

To test a saga on its own, complete what it waits for with Story.signal, and move its durable clocks with Story.clock:

Story.saga(ExecuteTransfer, transfer, { _tag: "Running" }),
Story.signal(ExecuteTransfer, transfer, Decision, "approved"),

A step that calls an external system waits until the story answers the call, as a policy does. A run that dies fails the story, unless a step expects it with Story.saga(ExecuteTransfer, transfer, { _tag: "Died" }).

A saga runs wherever its model runs: in memory in a story, and on Cloudflare Durable Objects in production, with each run’s steps stored durably.

Next, External System: how the model talks to the world outside it.