Saga pattern
Learn how the saga pattern maintains data consistency across microservices without distributed locks, and why compensating transactions are the key to surviving partial failure.
TL;DR
- A saga is a sequence of local database transactions across multiple services. Each step publishes an event (or sends a command) that triggers the next. If any step fails, compensating transactions undo the already-committed steps in reverse order.
- The core trade-off is eventual consistency vs atomicity: a saga does not give you ACID across service boundaries. It gives you a best-effort consistency guarantee through compensation. You see intermediate states.
- Two implementation styles: choreography (services react to events on a shared bus, no coordinator) and orchestration (a central saga coordinator sends commands and tracks state). Orchestration is almost always the right choice once your saga has more than three steps.
- The hardest part is not the happy path. It is making compensating transactions idempotent and reliable, because your network will drop the compensation message and the saga will retry.
- Pair sagas with the Outbox Pattern to guarantee event delivery. Without it, your saga loses events silently when a service crashes between writing to its DB and publishing to the broker.
The Problem
It is Friday evening. Your e-commerce platform is processing 50,000 orders per hour. Each order flows through four services: Order Service (creates the record), Inventory Service (reserves stock), Payment Service (charges the card), and Notification Service (emails the receipt).
These four services each have their own database. ACID transactions do not cross service boundaries. The databases do not share a transaction log.
When your Payment Service gets a 429 from Stripe at step three, the order already exists and the inventory is already reserved. You now have a ghost order and phantom reserved stock, with no automatic rollback in sight.
The naive fix is Two-Phase Commit (2PC). A transaction coordinator asks all participants to prepare (phase 1), then issues a global commit or abort (phase 2). It gives you something close to atomicity, but the coordinator is a single point of failure.
If it crashes between phase 1 and phase 2, every participant holds its row locks indefinitely. In practice, this causes full system freezes.
The fundamental problem is that distributed systems need a consistency model that tolerates partial failure without requiring a global lock. The saga pattern is the answer. Everything else in this article explains exactly how.
Every system I have seen skip this design conversation eventually retrofitted saga-like compensation logic after a production incident revealed the gap.
One-Line Definition
A saga sequences local transactions across services, publishing an event or message after each step to trigger the next, and running compensating transactions in reverse order when any step fails.
Analogy
Think about booking a holiday through a travel agent. The agent books your flight, reserves a hotel, and arranges a rental car (three separate transactions with three separate companies). If the car rental falls through, the agent does not magically undo the first two.
The agent calls the hotel to release the reservation, then calls the airline to cancel the flight. Each cancellation is an explicit compensating action.
The agent does not hold all three companies frozen while deciding. They act, observe outcomes, and compensate when something goes wrong.
That is exactly what a saga does. The agent is the orchestrator, each booking company is a service, and each cancellation call is a compensating transaction.
Solution Walkthrough
A saga breaks a multi-step business operation into individual local transactions, each scoped to a single service and its database. Each transaction either succeeds and publishes a success event, or fails and the saga triggers compensations.
The key insight: compensating transactions are not database rollbacks. They are new, explicit business operations. CANCEL_ORDER sets the order status to CANCELLED, records who cancelled it and when, and is a new write, not a SQL undo.
For your interview: the moment you introduce multiple services with separate databases, assume you need a saga for any workflow that spans more than one of them.
Two Implementation Styles
There is no single correct implementation of a saga. The saga pattern is a logical idea, and you implement it one of two ways.
Choreography
In a choreography-based saga, there is no central coordinator. Each service listens to the message broker for the events it cares about, processes them, and publishes the next event. The workflow emerges from the connected chain of reactions.
Choreography is attractive because there is no central service to maintain, and teams can add new participants by subscribing to the right event without touching other services. But the workflow is invisible. If you want to know what step a saga is on, you have to reconstruct it from the event log.
Orchestration
In an orchestration-based saga, a central saga orchestrator owns the state machine. It sends commands to each service, waits for their replies, and decides what to do next. The workflow is explicit and visible.
I always recommend orchestration by default unless you are working with a very small team, a very short saga (two or three steps), and the services are owned by the same team. The moment you have cross-team ownership or five or more steps, orchestration pays for itself on the first debugging session. Choreography looks elegant in architecture diagrams but it is a debugging nightmare at 3 a.m.
Implementation Sketch
Here is a typed sketch of an orchestration-based saga in TypeScript. This is deliberately simplified to show the state machine mechanics.
// Orchestration-based saga: state machine skeleton
class OrderSagaOrchestrator {
async execute(ctx: SagaContext): Promise<void> {
try {
await this.step("createOrder", ctx,
() => orderService.create(ctx.orderId));
ctx.state = "ORDER_CREATED";
await this.step("reserveInventory", ctx,
() => inventoryService.reserve(ctx.orderId, ctx.qty));
ctx.state = "INVENTORY_RESERVED";
await this.step("chargePayment", ctx,
() => paymentService.charge(ctx.orderId, ctx.amount));
ctx.state = "PAYMENT_CHARGED";
await this.step("sendNotification", ctx,
() => notificationService.send(ctx.orderId));
ctx.state = "COMPLETED";
} catch {
const lastCommittedState = ctx.state; // e.g. "INVENTORY_RESERVED"
ctx.state = "COMPENSATING";
await this.compensate(ctx, lastCommittedState);
}
}
private async compensate(ctx: SagaContext, failedAtState: string): Promise<void> {
const rank: Record<string, number> = {
ORDER_CREATED: 1, INVENTORY_RESERVED: 2, PAYMENT_CHARGED: 3,
};
const at = rank[failedAtState] ?? 0;
if (at >= 3) await paymentService.refund(ctx.orderId); // C3
if (at >= 2) await inventoryService.release(ctx.orderId); // C2
if (at >= 1) await orderService.cancel(ctx.orderId); // C1
ctx.state = "COMPENSATED";
}
private async step(name: string, ctx: SagaContext, fn: () => Promise<void>): Promise<void> {
await sagaRepository.recordStep(ctx.orderId, name, "STARTED");
await fn();
await sagaRepository.recordStep(ctx.orderId, name, "COMPLETED");
}
}
Notice the sagaRepository.recordStep call wrapping every step. This is not optional. If the orchestrator crashes mid-saga and restarts, it reads the step log and resumes from the last COMPLETED step. Without this, every restart re-executes steps from the beginning, causing duplicate charges, double-reservations, and a very bad day.
When It Shines
Ok, but here is the thing most people miss in interviews: the saga pattern is not a general-purpose transaction mechanism. It is specifically designed for one scenario. Use it when:
- You have two or more microservices, each with their own database, that must participate in the same business operation
- The workflow can tolerate intermediate visible states (e.g., "order pending" before inventory is confirmed)
- Steps are sequential with clear dependencies (each step depends on the previous one succeeding)
- Each step has a well-defined compensating transaction that is reliable and idempotent
- Your team can accept eventual consistency as the end state
Do not use it:
- When all your data lives in a single database (use regular ACID transactions)
- When you need strict atomicity with no visible intermediate state (consider rethinking the design; this is rarely a hard requirement)
- When compensating transactions cannot be reasoned about because the downstream effects are irreversible (example: you cannot un-send 10 million push notifications)
- For read-heavy workflows (sagas are a write coordination pattern)
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.
Related Articles
Learn how the Outbox pattern eliminates the dual-write problem in distributed systems, guaranteeing every database write produces its corresponding event even when brokers and services crash mid-flight.
Learn how event sourcing stores state as an immutable event log, enabling audit trails, time travel queries, and replayable projections at any scale.
Learn how the circuit breaker pattern stops cascading failures by failing fast on broken dependencies, and how three states protect your system.