Datasagacompensating transactionorchestrationchoreographystate machine

Saga Pattern

A saga is a business transaction spread over several services as a sequence of local transactions, each with a compensating action; the flow either completes or is unwound step by step, passing through pending states the user can see, and driven either by an orchestrator or by a chain of events.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

Create Order → Reserve Inventory → Charge Payment → Ship must behave as one business operation even though each step commits in a different service. A saga gives the flow a defined outcome — completed or compensated — without holding any lock across the network.

Happy path and compensation

The saga for an order has four forward steps, each a local transaction in its own service: create order (status pending), reserve inventory, charge payment, ship. If every step succeeds the order is confirmed. If a step fails — payment declined at step 3 — the saga does not roll back; nothing can, the earlier steps are committed. Instead it runs the compensating actions of the completed steps in reverse: release inventory, then cancel order. The order ends in cancelled, the stock is available again, and no card was charged.

A compensation is a semantic undo, not a database rollback. Releasing a reservation is a new write that restores availability; it does not erase the fact that a reservation existed, and it may run after other customers already saw the stock as taken. The distinction matters for the things that cannot be undone: a card can be refunded (a new transaction that a customer sees on their statement), a shipment can be cancelled until the carrier picks it up, but an email that said "your order is confirmed" cannot be unsent — it can only be followed by another email. Steps with no meaningful undo go last, after every step that can fail; this is the single most important ordering rule in saga design. Ship after charge, notify after ship.

Forward steps and their compensations
captureddeclinedStartCreate order (pending)Reserve inventoryCharge paymentShipRelease inventoryConfirmedCancel orderCancelled
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The saga as a state machine

A saga is a state machine whose state is persisted after every transition. That is what makes it survive crashes: an orchestrator that dies after reserving inventory restarts, reads state = inventory_reserved, and continues with the payment step rather than starting over. Every transition is triggered by a reply or an event and moves to exactly one next state; a step that times out is a transition too (payment_pendingcompensating after 60 s), because a saga that waits forever for a reply is a locked resource with extra steps. The states also define what the user sees: pending on the order page, "we are confirming your payment" — an honest intermediate state, not a fake confirmed that might later flip.

Every step and every compensation must be idempotent, because the driver will retry them after any crash or timeout, and a retry may arrive after the original finally succeeded. Reserve with the order id as the reservation key; charge with an idempotency key derived from the saga id; release a reservation by id so releasing twice is a no-op. The state machine plus idempotent steps is the whole recovery story: replay from the persisted state, re-issue the current step, ignore duplicate replies. This is the same discipline as Workflow State Graph in agent systems — explicit states, explicit transitions, persisted between steps.

Orchestrator: transitions are data, every step is retried idempotently
1type State = 'created' | 'inventory_reserved' | 'paid' | 'shipped' | 'confirmed'
2 | 'releasing_inventory' | 'cancelling' | 'cancelled'
3
4const forward: Record<string, { run: (s: Saga) => Promise<void>; next: State; onFail: State }> = {
5 created: { run: s => inventory.reserve(s.orderId, s.items), next: 'inventory_reserved', onFail: 'cancelling' },
6 inventory_reserved: { run: s => payment.charge(s.orderId, s.total, s.id), next: 'paid', onFail: 'releasing_inventory' },
7 paid: { run: s => shipping.ship(s.orderId), next: 'shipped', onFail: 'releasing_inventory' }, // refund handled in compensation
8 shipped: { run: s => orders.confirm(s.orderId), next: 'confirmed', onFail: 'shipped' },
9}
10const backward: Record<string, { run: (s: Saga) => Promise<void>; next: State }> = {
11 releasing_inventory: { run: s => inventory.release(s.orderId), next: 'cancelling' },
12 cancelling: { run: s => orders.cancel(s.orderId), next: 'cancelled' },
13}
14
15async function step(s: Saga) { // called on every event, timeout and restart
16 const t = forward[s.state] ?? backward[s.state]
17 if (!t) return // terminal
18 try { await withTimeout(t.run(s), 60_000); await persist(s.id, t.next) }
19 catch { await persist(s.id, 'onFail' in t ? t.onFail : s.state) } // compensation retries itself
20}

Orchestration vs choreography

Two ways to drive the machine. Orchestration: one component (the order saga orchestrator, often inside the order service) holds the state and sends commands — ReserveInventory, ChargePayment — waiting for replies. The flow is in one place, readable as the code above, and the orchestrator knows every saga's current state. Choreography: no central driver; each service reacts to events — inventory listens for OrderCreated and emits StockReserved, payment listens for StockReserved and emits PaymentCaptured or PaymentFailed, inventory listens for PaymentFailed and releases. The flow is emergent: it exists only as the sum of subscriptions, and the state of one saga is scattered across services.

Choreography is attractive for two or three steps with obvious events and no compensation; it is Event-Driven Architecture doing what it does well. It degrades as steps and failure paths multiply: nobody can answer "where is order 91 right now?" without a trace, adding a step means editing subscriptions in several services, and a missing compensation subscription — inventory never listened for PaymentFailed — fails silently until an order ships unpaid. Orchestration costs a component that must be highly available and adds a small hub of coupling, but makes the flow explicit, testable and observable. For anything with compensation, prefer orchestration.

Orchestration vs choreography
OrchestrationChoreography
Where the flow livesOne orchestrator (code + state table)Spread across every service's subscriptions
CouplingServices depend on the orchestrator's commandsServices depend on each other's event names
Answer "where is saga 91?"One row lookupReconstruct from a distributed trace
Adding a stepEdit one state machineEdit subscriptions in N services
CompensationExplicit reverse pathEach service must subscribe to every failure event — easy to miss
Failure of the driverOrchestrator is a component to keep available (it is stateless over its table)No single driver; a lost event stalls the saga silently
Best forFlows with ≥ 3 steps or any compensation2–3 steps, fire-and-forget reactions, no undo

Timeouts, pending states and what the user sees

Every step has a timeout and the timeout is a transition. Payment providers answer in 2 s or in 40 s; a saga must decide when "no reply" becomes "failed", and what it does then — compensating a charge that may have succeeded requires querying the provider by idempotency key first, then refunding if it did. Timeouts are also how the saga bounds its pending window: an order can sit in payment_pending for a minute, not for a day, and the state machine has a path out of every non-terminal state, including a path that escalates to a human queue when automation cannot decide.

Design the pending states as product features, not as embarrassments. "Order received — confirming payment" with a spinner that resolves within seconds is what every large retailer shows, because it is the truthful representation of a saga in progress. Faking confirmed and silently flipping to cancelled five seconds later is the dishonest version, and it is what teams build when they try to hide the saga from the UI. The read side of this — how the order page learns that the state changed — is a CQRS concern.

Key points

  • A saga is a sequence of committed local transactions with a compensating action per step; failure runs the compensations in reverse.
  • Compensation is semantic undo, not rollback: refund, release, cancel. Order steps so the ones without an undo (ship, email) come last.
  • Persist the saga as a state machine; every reply, timeout and restart is a transition, and every step is idempotent so replay is safe.
  • Orchestration keeps the flow in one readable, observable place; choreography spreads it across subscriptions and is easy to leave with a missing compensation.
  • Pending states are honest product states with a bounded window and a way out — including escalation to a human.

Saga: happy path and compensation

Saga: happy path and compensation
Create Order → Reserve Inventory → Charge Payment → Ship. Choose who coordinates and where it fails; on failure the saga runs compensations backwards.
create / cancelreserve / releasecharge / refundshipOrder saga orchestratorOrder serviceInventory servicePayment serviceShipping service
Coordination
Fail at
PENDINGINVENTORY_RESERVEDPAIDSHIPPED/CANCELLED
t+0Orchestrator → order: Create Order → ok
t+120Orchestrator → inv: Reserve Inventory → ok
t+240Orchestrator → pay: Charge Payment — FAILED (card declined)
t+360Orchestrator → inv: Release Inventory → done
t+480Orchestrator → order: Cancel Order → done
steps run
1
compensations run
0
order state
PENDING
user saw meanwhile
checkout spinner
Step 1: the order service commits its own local transaction and replies to the orchestrator, which persists the saga state before issuing the next command. Order state: PENDING.
1/5

How data moves through it

One request or event, hop by hop.

  1. 1Client → Order service: POST /orders; saga row state=created written with the order in one transaction; 202 + order id.
  2. 2Orchestrator → Inventory service: ReserveInventory(orderId, items); reply StockReserved; state=inventory_reserved.
  3. 3Orchestrator → Payment service: ChargePayment(orderId, total, key=sagaId); reply PaymentFailed; state=releasing_inventory.
  4. 4Orchestrator → Inventory service: ReleaseInventory(orderId) (idempotent by order id); state=cancelling.
  5. 5Orchestrator → Order service: CancelOrder(orderId, reason); state=cancelled; Client polling GET /orders/{id} sees cancelled: payment declined.

When to use — and when not

Use it when
  • A business operation spans services with separate databases and must end in a defined outcome (orders, bookings, transfers, onboarding).
  • Steps involve external providers (payments, carriers) that can never join a shared transaction but do offer an undo.
  • The intermediate states are acceptable to expose — the user can be told "pending" for seconds to minutes.
Avoid it when
  • The steps can live in one service and one database — then use a local transaction and skip the saga entirely.
  • Intermediate states are unacceptable and the participants are all yours: consider keeping the flow inside one service, or a short 2PC inside one cluster.
  • The steps are independent side effects with no required outcome (analytics, recommendations) — plain events with retries are enough.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

No lock crosses the network, so sagas scale; in exchange every step needs an idempotent forward action, a compensating action, a timeout and a visible pending state.

How it fails

  • Missing compensation path in a choreographed saga: PaymentFailed is published but nobody releases the stock or cancels the shipment — the order ships unpaid.
  • A non-undoable step placed early: the confirmation email goes out before payment is captured, then payment fails.
  • A step without a timeout: the saga waits forever for a reply that will never come, holding the reservation and the pending order.
  • Non-idempotent step retried after a timeout: the provider actually succeeded, the retry charges a second time.
  • Compensation that itself fails and is not retried: the release call hits a deploy, the stock stays reserved for nobody.
  • Orchestrator state kept in memory: a restart forgets every in-flight saga.

How it scales

  • Orchestrators are stateless over a saga table; run several, each claiming sagas with FOR UPDATE SKIP LOCKED, and scale by saga throughput.
  • Each step scales with its own service; the saga adds no lock, so throughput is bounded by the slowest step, not by coordination.
  • Partition saga events by saga id so one saga's events stay ordered on one partition (Kafka-Style Logs: Topics, Partitions, Offsets).
  • Long-running sagas (days: returns, subscriptions) belong in a workflow engine that persists timers; a hand-rolled orchestrator should stay in the seconds-to-minutes range.

How it interacts with databases, queues, caches, APIs and external systems

  • Database: the saga state table lives with the orchestrator; each participant keeps its own local transaction and outbox (see Distributed Transactions).
  • Queue/log: commands and replies (orchestration) or events (choreography); at-least-once, so every handler is idempotent.
  • External APIs: payment and shipping providers are steps with compensations (refund, cancel); query-by-key before compensating an ambiguous timeout.
  • API: exposes saga state as order state (pending, confirmed, cancelled) via polling or push.
  • Observability: one trace id per saga across every step — the only way to answer "where is order 91?" under choreography.