Saga
Context
Section titled “Context”Each service now owns its own database. Placing an order, though, is one business operation that spans three of them: the Order service records the order, the Customer service reserves the buyer’s credit, and the Inventory service holds the stock. In the monolith this was a single transaction — all three changes committed together or none did. With three private databases, that single transaction is impossible.
Problem
Section titled “Problem”You might reach for a distributed transaction. The classic mechanism, two-phase commit (2PC), asks every participant to prepare, then tells them all to commit. It does give you atomicity, but at a steep price: every participant holds locks for the whole duration, one slow or crashed participant stalls everyone, and many modern data stores and message brokers do not support it at all. 2PC trades availability and scalability for strong consistency — the opposite of what you decomposed your system to get.
So the forces are: you need a multi-service operation to either fully complete or fully unwind, but you cannot hold a global lock, you cannot assume every service shares a transaction manager, and you must stay available even when one service is slow.
Solution
Section titled “Solution”A saga is a sequence of local transactions. Each step runs entirely inside one service, commits to that service’s own database, and then triggers the next step. Because each step commits independently, there is no global lock and no shared transaction manager.
The catch is rollback. Once a local transaction commits, you cannot simply roll it back later. Instead, each step has a compensating transaction — a semantically inverse local transaction that undoes its effect. If a step fails partway through the saga, the saga runs the compensations for every step that already succeeded, in reverse order, returning the system to a consistent state.
There are two ways to coordinate the steps:
- Choreography — there is no central coordinator. Each service listens for events from the previous step and reacts by performing its local transaction and publishing its own event. The workflow is the sum of the subscriptions.
- Orchestration — a single coordinator (the orchestrator) tells each service what to do and waits for the reply, driving the saga step by step and deciding when to compensate.
Choreography: services react to each other’s events
Section titled “Choreography: services react to each other’s events”sequenceDiagram
participant O as Order Service
participant C as Customer Service
participant I as Inventory Service
O->>O: create order (PENDING)
O-->>C: OrderCreated
C->>C: reserve credit
C-->>I: CreditReserved
I->>I: reserve stock
alt stock available
I-->>O: StockReserved
O->>O: approve order (CONFIRMED)
else out of stock
I-->>C: StockReservationFailed
C->>C: release credit (compensate)
C-->>O: CreditReleased
O->>O: reject order (CANCELLED)
end Orchestration: a coordinator drives each step
Section titled “Orchestration: a coordinator drives each step”sequenceDiagram
participant SO as Order Saga Orchestrator
participant O as Order Service
participant C as Customer Service
participant I as Inventory Service
SO->>O: createOrder()
O-->>SO: ok (order PENDING)
SO->>C: reserveCredit()
C-->>SO: ok
SO->>I: reserveStock()
alt stock available
I-->>SO: ok
SO->>O: approveOrder()
O-->>SO: order CONFIRMED
else out of stock
I-->>SO: failed
SO->>C: releaseCredit() (compensate)
C-->>SO: ok
SO->>O: rejectOrder() (compensate)
O-->>SO: order CANCELLED
end Example
Section titled “Example”Here is an orchestration saga for the order flow. The orchestrator runs the forward steps in order; if a step throws, it runs the compensations for the steps that already succeeded, in reverse. Each example is self-contained and idiomatic to its language.
type Ctx = { orderId: string; customerId: string; amount: number; sku: string };
interface Step { name: string; action: (ctx: Ctx) => Promise<void>; compensate: (ctx: Ctx) => Promise<void>;}
async function runSaga(ctx: Ctx, steps: Step[]): Promise<void> { const done: Step[] = []; try { for (const step of steps) { await step.action(ctx); done.push(step); } } catch (err) { // Undo completed steps in reverse order. for (const step of done.reverse()) { await step.compensate(ctx).catch((e) => console.error(`compensation failed for ${step.name}`, e), ); } throw err; }}
const orderSaga: Step[] = [ { name: 'createOrder', action: (c) => orderSvc.create(c.orderId, c.customerId), compensate: (c) => orderSvc.reject(c.orderId), }, { name: 'reserveCredit', action: (c) => customerSvc.reserveCredit(c.customerId, c.amount), compensate: (c) => customerSvc.releaseCredit(c.customerId, c.amount), }, { name: 'reserveStock', action: (c) => inventorySvc.reserve(c.sku), compensate: (c) => inventorySvc.release(c.sku), },];
await runSaga(ctx, orderSaga);from dataclasses import dataclassfrom typing import Awaitable, Callable
@dataclassclass Ctx: order_id: str customer_id: str amount: int sku: str
@dataclassclass Step: name: str action: Callable[[Ctx], Awaitable[None]] compensate: Callable[[Ctx], Awaitable[None]]
async def run_saga(ctx: Ctx, steps: list[Step]) -> None: done: list[Step] = [] try: for step in steps: await step.action(ctx) done.append(step) except Exception: # Undo completed steps in reverse order. for step in reversed(done): try: await step.compensate(ctx) except Exception as e: print(f"compensation failed for {step.name}: {e}") raise
order_saga = [ Step("createOrder", lambda c: order_svc.create(c.order_id, c.customer_id), lambda c: order_svc.reject(c.order_id)), Step("reserveCredit", lambda c: customer_svc.reserve_credit(c.customer_id, c.amount), lambda c: customer_svc.release_credit(c.customer_id, c.amount)), Step("reserveStock", lambda c: inventory_svc.reserve(c.sku), lambda c: inventory_svc.release(c.sku)),]
await run_saga(ctx, order_saga)type Ctx struct { OrderID string CustomerID string Amount int SKU string}
type Step struct { Name string Action func(Ctx) error Compensate func(Ctx) error}
func RunSaga(ctx Ctx, steps []Step) error { var done []Step for _, step := range steps { if err := step.Action(ctx); err != nil { // Undo completed steps in reverse order. for i := len(done) - 1; i >= 0; i-- { if cerr := done[i].Compensate(ctx); cerr != nil { log.Printf("compensation failed for %s: %v", done[i].Name, cerr) } } return err } done = append(done, step) } return nil}
orderSaga := []Step{ {"createOrder", func(c Ctx) error { return orderSvc.Create(c.OrderID, c.CustomerID) }, func(c Ctx) error { return orderSvc.Reject(c.OrderID) }}, {"reserveCredit", func(c Ctx) error { return customerSvc.ReserveCredit(c.CustomerID, c.Amount) }, func(c Ctx) error { return customerSvc.ReleaseCredit(c.CustomerID, c.Amount) }}, {"reserveStock", func(c Ctx) error { return inventorySvc.Reserve(c.SKU) }, func(c Ctx) error { return inventorySvc.Release(c.SKU) }},}
if err := RunSaga(ctx, orderSaga); err != nil { log.Printf("order saga rolled back: %v", err)}use std::future::Future;use std::pin::Pin;
type BoxFut = Pin<Box<dyn Future<Output = Result<(), String>>>>;
struct Step { name: &'static str, action: Box<dyn Fn(&Ctx) -> BoxFut>, compensate: Box<dyn Fn(&Ctx) -> BoxFut>,}
#[derive(Clone)]struct Ctx { order_id: String, customer_id: String, amount: i64, sku: String,}
async fn run_saga(ctx: &Ctx, steps: &[Step]) -> Result<(), String> { let mut done: Vec<&Step> = Vec::new(); for step in steps { if let Err(e) = (step.action)(ctx).await { // Undo completed steps in reverse order. for s in done.iter().rev() { if let Err(ce) = (s.compensate)(ctx).await { eprintln!("compensation failed for {}: {ce}", s.name); } } return Err(e); } done.push(step); } Ok(())}Resulting context
Section titled “Resulting context”What you gain:
- Consistency without 2PC. Multi-service operations either complete fully or unwind through compensation, with no global locks and no shared transaction manager — services stay available and loosely coupled.
- Works with anything. Because each step is a plain local transaction plus a message, sagas work across any mix of databases and brokers.
What it costs you:
- Eventual consistency, not isolation. Between steps the system is in a partial state. Another transaction could observe an order whose credit is reserved but whose stock is not yet confirmed. You must design for these intermediate states (for example, with a semantic lock such as a
PENDINGstatus) rather than rely on database isolation. - You must design compensations. Every step that has an observable effect needs a sensible inverse, and compensations must be idempotent and able to handle being retried.
- Coordination complexity. Choreography spreads the workflow across many subscriptions and can become hard to follow as steps grow; orchestration centralizes the logic but adds a component that must itself be reliable and recoverable.
A practical rule: prefer choreography for short, simple flows with few participants, and orchestration once the flow has branches, several steps, or needs to be reasoned about as a whole.
Related patterns
Section titled “Related patterns”- Database per Service — the boundary that makes sagas necessary.
- Event Sourcing — a reliable way to publish the events a saga depends on.
- CQRS — often used to query the intermediate state a saga produces.