The problem: no shared transaction
Inside one database, a failed step is trivial: ROLLBACK. Once Order, Payment, Inventory and Shipping are separate services with separate databases, that word stops existing. Each service commits on its own, immediately and independently.
So the interesting question is not the happy path. It is: payment captured ₹4,999, inventory says the last unit sold ninety milliseconds ago. The order row exists, the customer is charged, and there is nothing to ship.
The core idea
A saga is a sequence of local transactions. Each step commits on its own, and each step has a compensating action that semantically undoes it. If step N fails, you run the compensations for steps N−1 … 1 in reverse order.
- Forward path: T1, T2, T3, T4 — each atomic within its own service.
- Backward path: C3, C2, C1 — business-level reversals, not rollbacks.
- The system is never “locked”; it is temporarily inconsistent and then repaired.
A checkout saga, step by step
Designing compensating actions
The hardest part of a saga is not the framework, it is admitting that some things cannot be undone — only offset.
| Forward action | Compensation | Note |
|---|---|---|
| Capture payment | Issue refund | Fees may not be recoverable |
| Reserve stock | Release reservation | Clean; prefer reservations over decrements |
| Send confirmation email | Send correction email | Cannot be unsent — order it last |
| Create shipment label | Void label | Time-bounded by the carrier |
| Award loyalty points | Deduct points | Guard against negative balances |
Rules that save you
- Put irreversible steps last, so fewer compensations exist.
- Prefer reservations (pending state) over destructive writes.
- Compensations must be idempotent — they will be retried.
- Compensations must not fail permanently; if they can, they need a dead-letter and an alert.
Choreography vs orchestration
| Choreography | Orchestration | |
|---|---|---|
| Control | Each service reacts to events | A coordinator issues commands |
| Coupling | Low, but implicit | Higher, but explicit |
| Visibility | Poor — flow lives nowhere | Good — one state machine |
| Best for | 3 steps or fewer | Anything with money in it |
| Failure handling | Scattered | Centralised, testable |
Rule of thumb: start choreographed, switch to an orchestrator the first time someone asks “where is this order stuck?” and nobody can answer in under a minute.
Orchestrator sketch
type Step = {
name: string;
run: (ctx: Ctx) => Promise<void>;
compensate: (ctx: Ctx) => Promise<void>;
};
async function runSaga(sagaId: string, steps: Step[], ctx: Ctx) {
const done: Step[] = [];
for (const step of steps) {
try {
await saveState(sagaId, { at: step.name, status: "running" });
await step.run(ctx); // idempotent, keyed by sagaId
done.push(step);
} catch (err) {
await saveState(sagaId, { at: step.name, status: "compensating" });
for (const s of done.reverse()) {
await retryForever(() => s.compensate(ctx));
}
await saveState(sagaId, { status: "compensated" });
throw err;
}
}
await saveState(sagaId, { status: "completed" });
}Every run and compensate call carries the saga id as an idempotency key, because the orchestrator can crash after the call and before the state write.
Pitfalls and how they bite
- No isolation. Other transactions can read the intermediate state. Model it explicitly:
PENDING,CONFIRMED,CANCELLED, and never show pending as final. - Lost compensations. A refund that silently fails is worse than the original bug. Compensations need their own retry queue and alerting.
- Unbounded sagas. Add timeouts per step; a saga stuck for hours is an outage nobody paged for.
- Non-idempotent steps. At-least-once delivery plus a non-idempotent capture equals double charges.
Interview framing
Say this: “Because each service owns its data, I will use a saga with an orchestrator. Payment capture is compensated by a refund, stock is reserved rather than decremented, and the order stays PENDING until all steps confirm. Every call carries the saga id as an idempotency key, and compensation failures go to a dead-letter queue with an operator runbook.” That paragraph covers consistency, isolation, retries and operations in four sentences.