Saga Pattern
When one big transaction isn't available, undo your way back instead.
The simple version
When a single operation spans multiple independent systems, you can’t wrap it all in one atomic transaction. So instead of “all or nothing,” you do each step in sequence — and if one fails partway through, you run explicit undo steps for everything that already succeeded.
The analogy
You’re booking a trip: flight, hotel, rental car — three different companies, three different systems. There’s no single “undo button” spanning all three; no travel-god transaction that can roll back an airline, a hotel chain, and a car rental company simultaneously as one atomic unit.
So here’s what actually happens: you book the flight — success. You book the hotel — success. You try the rental car — declined, no cars available.
You don’t just shrug and leave a half-booked trip. You cancel the hotel, then cancel the flight — undoing what you already did, step by step, in reverse. Each of those cancellations is its own transaction with its own company. There’s no magic global rollback; there’s a deliberate compensating action for each step that already happened.
That’s a Saga: a sequence of local transactions, each with a defined “undo” if a later step fails.
The bridge
This shows up constantly in distributed systems — microservices, in particular — because each service typically owns its own database. You genuinely cannot run one ACID transaction across the Orders DB, the Inventory DB, and the Payments DB; they’re separate systems with no shared transaction boundary.
A saga makes the trade explicit:
- Each step is a local transaction in one service, committed independently.
- Each step has a compensating transaction — the specific action that semantically undoes it (not a database rollback, since it already committed).
- If a step fails, run the compensations for every step that already succeeded, in reverse order.
Two common ways to coordinate the steps:
- Choreography — each service reacts to events from the previous one (Order Placed → Inventory reserves stock → emits Reserved → Payment charges → …). No central conductor; services listen and react.
- Orchestration — one coordinator explicitly calls each service in order and decides what to do on failure. Easier to reason about once you have more than a handful of steps.
In code
A (simplified) orchestrated saga for the trip-booking example:
type SagaStep = {
action: () => Promise<void>;
compensate: () => Promise<void>;
};
async function runSaga(steps: SagaStep[]) {
const completed: SagaStep[] = [];
for (const step of steps) {
try {
await step.action();
completed.push(step);
} catch (err) {
// Undo everything that already succeeded, most recent first
for (const done of completed.reverse()) {
await done.compensate();
}
throw err;
}
}
}
await runSaga([
{ action: () => bookFlight(trip), compensate: () => cancelFlight(trip) },
{ action: () => bookHotel(trip), compensate: () => cancelHotel(trip) },
{ action: () => bookCar(trip), compensate: () => cancelCar(trip) }, // fails here
]);
// → cancelHotel() then cancelFlight() run automatically, in that order
The crucial detail: compensate() isn’t a database rollback. It’s a real, deliberate business operation — “issue a cancellation,” “refund a charge,” “release reserved stock” — because by the time it runs, the original action already committed and other systems may have already observed it.
When you don’t need it
If every piece of state you’re touching lives in one database, just use a real transaction — it’s simpler, and it gives you actual atomicity instead of an approximation of it. Reach for a saga only when the steps genuinely span systems that can’t share a transaction, and be honest that “eventually consistent, with a brief window of partial state” is the trade-off you’re accepting to get there.