In a monolith, placing an order is one database transaction: decrement stock, record the payment authorisation, insert the order. If anything fails, the database rolls it all back. Once inventory, payments and orders are separate services with separate databases, that single transaction isn’t available. Two-phase commit across services is possible in theory, but it couples every participant’s availability and holds locks across network calls, so almost nobody uses it for this.
A saga is the alternative. Each service performs its own local transaction and publishes the result. If a later step fails, the saga runs compensating transactions for the steps that already completed. Interviewers like this topic because the happy path is easy and every interesting question is about the unhappy one.
An orchestrated order saga
The example uses an orchestrator: one component that tells each participant what to do and tracks the saga’s progress. Each step has a run and, where it makes sense, an undo:
async function placeOrder(sagaId, { sku, qty, cents, card }) {
const steps = [
{ name: 'reserve stock', run: () => inventory.reserve(sagaId, sku, qty), undo: () => inventory.release(sagaId) },
{ name: 'authorize payment', run: () => payments.authorize(sagaId, cents, card), undo: () => payments.void(sagaId) },
{ name: 'confirm order', run: () => {} }, // pivot: after this, the saga only moves forward
];
const done = [];
console.log(`saga ${sagaId}:`);
for (const step of steps) {
try { await step.run(); done.push(step); console.log(` ok ${step.name}`); }
catch (e) {
console.log(` FAIL ${step.name}: ${e.message}`);
for (const prev of done.reverse()) {
if (!prev.undo) continue;
console.log(` undo ${prev.name}: ${await retry(prev.undo)}`);
}
return 'REJECTED';
}
}
return 'CONFIRMED';
}The inventory participant’s release is written to be idempotent: it looks up the reservation by saga id and does nothing if there isn’t one. The script starts with 3 units in stock and runs four orders: a normal one, one with a declined card, one with a declined card where the inventory service times out twice during the compensation, and one asking for more stock than exists. Output from Node 22:
saga s-1:
ok reserve stock
ok authorize payment
ok confirm order
-> CONFIRMED | stock 1
saga s-2:
ok reserve stock
FAIL authorize payment: card declined
undo reserve stock: released 1 sku-1
-> REJECTED | stock 1
saga s-3:
ok reserve stock
FAIL authorize payment: card declined
retry 1 after: inventory timeout
retry 2 after: inventory timeout
undo reserve stock: released 1 sku-1
-> REJECTED | stock 1
saga s-4:
FAIL reserve stock: only 1 of sku-1 left
-> REJECTED | stock 1Walk through the failures:
- s-2: stock was reserved, the card was declined, and the compensation released the unit. Stock is back to 1, as if the order never happened.
- s-3: the same, except the compensation itself failed twice. The orchestrator retried until it worked. That’s the rule for compensations: they can’t be allowed to fail permanently, because a half-compensated saga leaves inconsistent data with nobody responsible for fixing it. Retrying is only safe because
releaseis idempotent; if a timeout hid a successful release, the next call returns “nothing to release” instead of adding stock twice. - s-4: the first step failed, so there was nothing to compensate.
Compensation is a new action, not a rollback
A database rollback makes it as if the transaction never happened. Nobody else ever saw its effects. A compensation is different: it’s a new business action that counteracts an earlier one, after other parts of the system may already have seen the earlier one.
That distinction is where the limits come from:
- Others saw the intermediate state. Between s-2’s reservation and its release, another customer looking at the product page saw 0 units and might have left. A reporting job might have counted the reservation. Sagas don’t provide isolation.
- Some effects can’t be undone. An email saying “your order is confirmed” can’t be unsent; the compensation is a second email apologising. A shipped parcel can’t be un-shipped; the compensation is a return process.
- Some undos cost money or time. Refunding a captured card payment can incur fees and take days to show on the customer’s statement. Voiding an authorisation before capture is much cleaner, which is why the example authorises rather than charges.
So the design question isn’t just “what’s the compensation for each step” but “which steps should happen early, when undoing is cheap, and which should happen late”.
Ordering steps around the pivot
The useful structure has three groups:
- Compensatable steps go first. Reserve stock, authorise (not capture) the payment, create the order in
PENDING. Each has a clean undo. - The pivot is the point of no return. Here, confirming the order. If it succeeds, the saga will complete; if it fails, everything before is compensated.
- Retriable steps come after the pivot. Capture the payment, send the confirmation email, create the shipment. These have no compensation because the saga has committed to going forward. They must be retried until they succeed, so they must be idempotent too.
Putting “send confirmation email” before “authorise payment” is the classic mistake: a declined card then requires an apology email. Moving it after the pivot removes the need for that compensation entirely.
Living without isolation
Because other requests can see a saga’s intermediate state, you design those states explicitly. Common countermeasures:
- Semantic locks. The order is created with status
PENDING. Other operations treat a pending order specially: the customer sees “processing”, a cancel request is queued until the saga finishes. - Reservations instead of decrements. Inventory tracks reserved stock separately from sold stock, so a reservation that’s later released never looked like a sale.
- Commutative updates. Designing operations so their order doesn’t matter (add and subtract from a balance) reduces conflicts between concurrent sagas.
- Rereading values. Before the pivot, recheck anything that might have changed during the saga, such as the price.
Durable state, orchestration and choreography
The example keeps the saga’s progress in memory. If the orchestrator crashed after reserving stock and before authorising payment, the reservation would be stuck forever. A real orchestrator persists the saga state (which step, what’s been done) in its own database, writes the command for the next step through an outbox, and on restart resumes every unfinished saga. Frameworks like Temporal, Axon or Eventuate Tram exist largely to handle this durability.
The alternative to an orchestrator is choreography: each service reacts to the previous service’s event (StockReserved triggers payment authorisation, PaymentDeclined triggers stock release). It has no central coordinator, which is appealing for two or three steps. Beyond that, the flow is spread across services and hard to follow, and compensation logic is scattered. Many teams start with choreography and move to orchestration when the saga grows.
Interview practice: the refund that failed
“An order saga captures payment, then calls the shipping service to book a courier. If booking fails, it refunds the payment. Last week the refund call failed with a timeout, the saga logged an error and stopped, and the customer was charged for an order that never shipped. What’s wrong with the design?”
Answer
Two things. First, step ordering: payment capture happens before an uncertain step, so the compensation is a refund, which is the expensive and slow kind of undo. I’d authorise payment early (compensated by a void), book the courier, and capture only after booking succeeds, making capture a retriable step after the pivot. If booking can’t be done synchronously, I’d make booking itself retriable after the pivot and accept that the order is confirmed before the courier is assigned.
Second, compensation handling: a compensation that gives up after one timeout leaves the saga half done. Compensations must be retried with backoff until they succeed, and they must be idempotent, which for a refund means passing an idempotency key to the payment provider, so a retry after a timeout that actually succeeded doesn’t refund twice. The saga state must be persisted so a crash doesn’t lose the obligation to compensate. If retries are exhausted, the saga should move to a “needs attention” state that alerts someone, rather than logging and stopping.