Design patternData Patterns
Saga
Run a multi-step transaction as local steps, each with a compensation; on failure, compensate the completed steps in reverse - consistency by explicit undo, not locks.
- Complexity
- High
- Seen in the wild
- Medium
- Also known as
- Compensating Transaction
Intent
A workflow that spans services or non-transactional systems cannot share one database transaction. A saga splits it into a sequence of steps, each committing locally, and gives every step a compensation - the operation that semantically undoes it. If a step fails, the saga runs the compensations of all completed steps in reverse order. The outcome is either every step done, or every effect undone: consistency by construction, with no locks held across services.
Forces
- Atomicity vs autonomy: each service owns its data and commits immediately, so intermediate states are visible to everyone until the saga finishes.
- Rollback vs compensation: databases undo; the world does not - a sent email has no
unsend, so compensations must be designed per step, not assumed. - Isolation vs concurrency: concurrent sagas can see each other's half-done work; without countermeasures, one saga reads a total another saga will later refund.
- Coordination vs coupling: a central orchestrator sees the whole flow and its failures; pure choreography couples every service to every event.
Therefore
Write each step as an action plus a compensation, execute steps in order, and on the first failure run the compensations of the completed steps in reverse - then record the outcome so the saga's result is queryable.
Two coordination styles exist: orchestrated, where one component drives the steps and owns the failure path (the shape below), and choreographed, where services react to each other's events. Start orchestrated - the failure path is the hard part, and one place that owns it is easier to make correct than five. Persist the saga's state with Command-style records so a crashed process resumes instead of re-running side effects.
Structure
Diagram source (Mermaid)
sequenceDiagram participant Saga participant Stock participant Card participant Email Saga->>Stock: reserve(order) Stock-->>Saga: reservation Saga->>Card: charge(order) Card-->>Saga: receipt Saga->>Email: schedule confirmation Email-->>Saga: delivery failed Saga->>Card: refund(receipt) Saga->>Stock: release(reservation)
Participants
- Saga (
CheckoutSaga): owns the step list and the failure path. Runs actions in order; on failure, runs compensations in reverse and records the outcome. - Step (
Step): one action plus its compensation. The compensation takes the action's result - refunding needs the receipt, releasing needs the reservation. - Local services (
Stock,Card,Email): each commits its own step immediately. They know nothing about the saga. - Saga log (in the demo,
outcome): the persisted record of what completed and what was compensated, so the result is auditable and resumable.
Use it when
- A workflow spans services or systems that cannot share a transaction: reserve stock, charge a card, send a confirmation.
- You can define a compensation for every step - refund a charge, release a reservation. If any step cannot be undone, handle it last or make it retriable instead.
- The flow is long or slow enough that holding a distributed lock would be worse than brief visibility of intermediate states.
- Steps are idempotent or can carry an idempotency key (Idempotent Consumer), because retries after crashes will re-run them.
Do not use it when
- All the data lives in one database. A local ACID transaction is strictly simpler and gives real isolation; a saga re-implements it badly.
- A step has no compensation - filing a legal record, dispatching a one-way physical shipment. Put non-compensable steps last, make them retriable until they succeed, or take the failure to a human workflow.
- Strict isolation is required - concurrent sagas would double-spend a balance. Countermeasures (semantic locks, commutative updates) add complexity that a transaction gives you for free.
- The workflow is two steps and rarely fails. An
ifwith an explicit undo is a saga with none of the machinery; earn the structure first.
Application grill
Inspect first: the multi-step flows, their failure points, and whether each step can run twice (they must survive the idempotent-consumer bar); where saga progress would live (a table in the existing database is the default); existing queues, schedulers or workflow engines (Temporal, Step Functions) already in the organization.
Missing pieces: saga state: one table (saga id, step, status) - no new infrastructure. Execution: a worker or cron to advance and time out sagas - platform cron or the existing queue. An orchestration engine (Temporal, AWS Step Functions) buys durable retries and visibility at the cost of a platform to operate; it earns its keep past a handful of long-running flows.
Ask before you build:
- Scale: how many concurrent sagas, and what timeout window precedes compensation?
- Performance: must steps run strictly in order, or can independent steps parallelize?
- Reliability: when compensation itself fails, what happens - alert plus a manual runbook, or automatic retry?
- Budget: a workflow platform versus a hand-rolled state machine - how many flows justify the platform?
- Implementation: how is every step made idempotent, and who owns the compensation tests?
Implementation steps
- List the steps in order and write the compensation next to each. If a compensation cannot be named, that step moves last or becomes a retriable action - decide before writing code.
- Make every action idempotent (or keyed), because crashes and retries re-run steps; a compensated-then-retried charge must not double-charge.
- Model a step as the pair
{ action, compensate }where compensation consumes the action's result. Type it once; every step obeys it. - Drive the saga from persisted state, not from a live stack: write "step N completed with result R" before running step N+1, so a restart resumes instead of repeating.
- On failure, run compensations in strict reverse order, each with its step's recorded result, and continue compensating even if a compensation itself fails - log the failure and keep going.
- Record the terminal outcome (completed, compensated at step N, and why). A saga whose outcome cannot be queried is a rumor.
- Choose orchestration first; move to choreography only when independent workflows need to react to the same events anyway.
Example
Checkout across three services. Stock and card succeed; the confirmation email fails. The saga refunds the charge, then releases the stock - compensations in reverse - and records the outcome.
type Step = {
name: string;
action: () => string; // returns the result its compensation consumes
compensate: (result: string) => string;
};
class CheckoutSaga {
private executed: { step: Step; result: string }[] = [];
outcome = "";
constructor(private readonly steps: Step[]) {}
run(): string {
for (const step of this.steps) {
try {
const result = step.action();
this.executed.push({ step, result });
console.log(` ok: ${step.name} -> ${result}`);
} catch (e) {
console.log(` FAILED: ${step.name} (${(e as Error).message})`);
this.compensate();
return (this.outcome = `compensated at "${step.name}"`);
}
}
return (this.outcome = "completed");
}
private compensate(): void {
while (this.executed.length) {
const { step, result } = this.executed.pop()!;
console.log(` undo: ${step.name} -> ${step.compensate(result)}`);
}
}
}
// Local services: each commits immediately, none knows the saga exists.
const stock: { reserved: string[] } = { reserved: [] };
const card: { charged: string[] } = { charged: [] };
const email = { failing: true };
const saga = new CheckoutSaga([
{
name: "reserve stock",
action: () => {
stock.reserved.push("sku-1");
return "reservation r-1";
},
compensate: (r) => {
stock.reserved.pop();
return `${r} released`;
},
},
{
name: "charge card",
action: () => {
card.charged.push("c-1");
return "receipt rc-1";
},
compensate: (r) => {
card.charged.pop();
return `${r} refunded`;
},
},
{
name: "send confirmation",
action: () => {
if (email.failing) throw new Error("mail queue down");
return "sent";
},
compensate: () => "n/a",
},
]);
console.log(saga.run()); // compensated at "send confirmation"
console.log(`stock: ${stock.reserved.length}, charges: ${card.charged.length}`);
// stock: 0, charges: 0 - every effect undone, in reverse orderIn production the steps are remote calls: the compensations must be retried until they succeed (Retry), and each action carries an idempotency key so a compensation followed by a retry cannot double-charge (Idempotent Consumer).
Trade-offs
Gains
- Multi-service workflows get all-or-nothing semantics without distributed locks.
- Each service stays autonomous: it commits locally and exposes nothing but its own operations.
- The compensation path is explicit, testable code - not an error message.
Costs
- No isolation: concurrent sagas see intermediate states, so countermeasures (semantic locks, commutative updates) must be designed in.
- Every step costs two implementations - the action and its compensation.
- The failure path is the product: a saga whose compensations were never tested has not been written.
- Orchestration centralizes logic; choreography scatters it - both are ongoing design decisions.
Relations
- Transactional Outbox: how a step's own two writes - its local state change and the "tell the saga" message - commit atomically; the saga's reliability rests on it.
- Idempotent Consumer: makes actions and compensations safe to re-run after crashes and at-least-once redelivery.
- Retry: compensations must ultimately succeed, so they retry until they do - a compensation that gives up leaves the saga inconsistent.
- Command: a step is a command with an undo; persisting commands as objects is what makes a crashed saga resumable.
Agent checklist
- Every step has a named compensation that consumes the action's result; non-compensable steps are last or retriable.
- Actions are idempotent or carry idempotency keys.
- Saga state is persisted per step; a restart resumes, never repeats a side effect.
- Compensations run in strict reverse order and continue even when one fails.
- The terminal outcome is recorded and queryable.
- Concurrent-saga interference has been considered, with a countermeasure or a written reason none is needed.
See also
- Transactional OutboxWrite the outgoing message in the same transaction as the business change; a relay publishes and marks it sent - the commit and the message become one fact, not two hopes.
- Idempotent ConsumerRecord the ids of processed messages and skip ids already seen, so a redelivered webhook or queue event processes once no matter how many times it arrives.
- CommandWrap an action and its arguments in an object so you can queue, log, retry or undo it separately from the code that triggers it.
- RetryRe-attempt only retryable, repeatable operations a bounded number of times, honour Retry-After, check cancellation between attempts, and surface the last error.
Original writing and examples. Topic coverage follows microservices.io, which explains this topic with its own illustrations.