StackPractices
advanced By Mathias Paulenko

Saga Pattern: Orchestration vs Choreography

Manage distributed transactions across multiple services by chaining local transactions with compensating actions for rollbacks. A microservices pattern.

Topics: design

Overview

The Saga Pattern manages distributed transactions across several services by breaking a long-running business operation into a sequence of local transactions. Each local transaction updates a single service and publishes an event or command that triggers the next step. If a step fails, the saga runs compensating transactions — business-level undo operations — against the steps that already committed, in reverse order.

A saga isn’t a database rollback. Each local transaction commits on its own, so its effects are visible to other operations while the saga is still running. Compensation is a semantic undo — refund the charge, cancel the reservation — not a physical one: the original commit stays in the history.

For a production-shaped implementation wired to an event bus, see the saga recipe.

When to Use

Use the Saga Pattern when:

  • A business operation spans several services that each own their database — e-commerce checkout (order → payment → inventory → shipping), travel booking, money transfers
  • Two-phase commit (2PC) is unavailable or unacceptable: it needs XA-capable stores, holds locks across services, and most NoSQL and REST stacks don’t support it
  • Steps run for seconds to days — payments, manual approvals, shipping — and must survive service restarts
  • Every step before the point of no return has a feasible compensating action: refund the payment, release the stock, cancel the shipment

Skip it when the whole operation fits in one database — a plain ACID transaction is simpler — when strict consistency between all steps is a legal requirement, or when a key step can’t be compensated. A saga you can’t undo is a liability, not a pattern.

Solution

An orchestrated saga in three languages. The orchestrator keeps an ordered list of steps, runs each action, records what completed, and on failure walks the committed steps backwards through their compensations. The charge_payment step always fails in the demo so you can watch the rollback path.

Python

from typing import Callable, List, Dict, Any
from dataclasses import dataclass

@dataclass
class SagaResult:
    success: bool
    data: Any = None
    error: str = None
    step_index: int = 0

class SagaStep:
    def __init__(self, name: str, action: Callable, compensation: Callable = None):
        self.name = name
        self.action = action
        self.compensation = compensation

class SagaOrchestrator:
    def __init__(self):
        self.steps: List[SagaStep] = []
        self.completed: List[Dict] = []

    def add_step(self, name: str, action: Callable, compensation: Callable = None):
        self.steps.append(SagaStep(name, action, compensation))

    def execute(self, context: Dict) -> SagaResult:
        self.completed = []
        for i, step in enumerate(self.steps):
            try:
                result = step.action(context)
                self.completed.append({"step": step.name, "context": dict(context)})
                print(f"Step '{step.name}' completed")
            except Exception as e:
                print(f"Step '{step.name}' failed: {e}")
                self.rollback(i)
                return SagaResult(success=False, error=str(e), step_index=i)
        return SagaResult(success=True, data=context)

    def rollback(self, failed_index: int):
        print(f"Rolling back {failed_index} completed steps...")
        for j in range(failed_index - 1, -1, -1):
            step = self.steps[j]
            if step.compensation:
                try:
                    state = self.completed[j]
                    step.compensation(state["context"])
                    print(f"Compensated '{step.name}'")
                except Exception as e:
                    print(f"Compensation failed for '{step.name}': {e}")

# Usage: Travel booking saga
saga = SagaOrchestrator()

saga.add_step(
    "reserve_flight",
    action=lambda ctx: ctx.update({"flight": "FL123"}) or True,
    compensation=lambda ctx: print("Canceling flight reservation")
)

saga.add_step(
    "reserve_hotel",
    action=lambda ctx: ctx.update({"hotel": "HT456"}) or True,
    compensation=lambda ctx: print("Canceling hotel reservation")
)

saga.add_step(
    "charge_payment",
    action=lambda ctx: (_ for _ in ()).throw(Exception("Payment declined")),
    compensation=lambda ctx: print("Refunding payment")
)

result = saga.execute({"user": "alice"})
print(f"Saga success: {result.success}")

JavaScript

class SagaStep {
  constructor(name, action, compensation) {
    this.name = name;
    this.action = action;
    this.compensation = compensation;
  }
}

class SagaOrchestrator {
  constructor() {
    this.steps = [];
    this.completed = [];
  }

  addStep(name, action, compensation) {
    this.steps.push(new SagaStep(name, action, compensation));
  }

  async execute(context) {
    this.completed = [];
    for (let i = 0; i < this.steps.length; i++) {
      try {
        await this.steps[i].action(context);
        this.completed.push({ step: this.steps[i].name, context: { ...context } });
        console.log(`Step '${this.steps[i].name}' completed`);
      } catch (e) {
        console.log(`Step '${this.steps[i].name}' failed: ${e.message}`);
        await this.rollback(i);
        return { success: false, error: e.message, stepIndex: i };
      }
    }
    return { success: true, data: context };
  }

  async rollback(failedIndex) {
    console.log(`Rolling back ${failedIndex} completed steps...`);
    for (let j = failedIndex - 1; j >= 0; j--) {
      const step = this.steps[j];
      if (step.compensation) {
        try {
          await step.compensation(this.completed[j].context);
          console.log(`Compensated '${step.name}'`);
        } catch (e) {
          console.log(`Compensation failed for '${step.name}': ${e.message}`);
        }
      }
    }
  }
}

// Usage
const saga = new SagaOrchestrator();
saga.addStep("reserveFlight",
  async (ctx) => { ctx.flight = "FL123"; },
  async () => console.log("Canceling flight")
);
saga.addStep("reserveHotel",
  async (ctx) => { ctx.hotel = "HT456"; },
  async () => console.log("Canceling hotel")
);
saga.addStep("chargePayment",
  async () => { throw new Error("Payment declined"); },
  async () => console.log("Refunding payment")
);

saga.execute({ user: "alice" }).then(r => console.log("Success:", r.success));

Java

import java.util.*;
import java.util.function.Consumer;

class SagaStep {
    String name;
    Consumer<Map<String, Object>> action;
    Consumer<Map<String, Object>> compensation;

    SagaStep(String name, Consumer<Map<String, Object>> action, Consumer<Map<String, Object>> compensation) {
        this.name = name;
        this.action = action;
        this.compensation = compensation;
    }
}

class SagaResult {
    boolean success;
    String error;
    int stepIndex;

    SagaResult(boolean success, String error, int stepIndex) {
        this.success = success;
        this.error = error;
        this.stepIndex = stepIndex;
    }
}

class SagaOrchestrator {
    private final List<SagaStep> steps = new ArrayList<>();
    private final List<Map<String, Object>> completed = new ArrayList<>();

    void addStep(String name, Consumer<Map<String, Object>> action, Consumer<Map<String, Object>> compensation) {
        steps.add(new SagaStep(name, action, compensation));
    }

    SagaResult execute(Map<String, Object> context) {
        completed.clear();
        for (int i = 0; i < steps.size(); i++) {
            try {
                steps.get(i).action.accept(context);
                completed.add(new HashMap<>(context));
                System.out.println("Step '" + steps.get(i).name + "' completed");
            } catch (Exception e) {
                System.out.println("Step '" + steps.get(i).name + "' failed: " + e.getMessage());
                rollback(i);
                return new SagaResult(false, e.getMessage(), i);
            }
        }
        return new SagaResult(true, null, steps.size());
    }

    void rollback(int failedIndex) {
        System.out.println("Rolling back " + failedIndex + " completed steps...");
        for (int j = failedIndex - 1; j >= 0; j--) {
            SagaStep step = steps.get(j);
            if (step.compensation != null) {
                try {
                    step.compensation.accept(completed.get(j));
                    System.out.println("Compensated '" + step.name + "'");
                } catch (Exception e) {
                    System.out.println("Compensation failed: " + e.getMessage());
                }
            }
        }
    }
}

// Usage
SagaOrchestrator saga = new SagaOrchestrator();
saga.addStep("reserveFlight",
    ctx -> ctx.put("flight", "FL123"),
    ctx -> System.out.println("Canceling flight")
);
saga.addStep("reserveHotel",
    ctx -> ctx.put("hotel", "HT456"),
    ctx -> System.out.println("Canceling hotel")
);
saga.addStep("chargePayment",
    ctx -> { throw new RuntimeException("Payment declined"); },
    ctx -> System.out.println("Refunding payment")
);

SagaResult result = saga.execute(new HashMap<>(Map.of("user", "alice")));
System.out.println("Success: " + result.success);

Explanation

Orchestration vs choreography

The code above is orchestrated: one coordinator decides which step runs next and when to compensate. In a choreographed saga there’s no coordinator — each service commits locally, emits an event, and the next service reacts to it:

StepServicePublishes on successCompensation on failure
1FlightFlightReserved → hotel listensCancelFlight on BookingFailed
2HotelHotelReserved → payment listensCancelHotel on BookingFailed
3PaymentPaymentCharged → order confirmedRefundPayment on BookingFailed

Orchestration buys you a single place to read, debug, and replay the flow, at the cost of a stateful coordinator to run and scale. Choreography removes the coordinator but scatters the flow across codebases — fine for two or three steps, painful past that. When services emit events, deliver them through the outbox pattern so the local commit and the event can’t desynchronize; if events are your source of truth, that pairs with event sourcing and CQRS on the read side.

A minimal choreographed version of the same booking flow. Each service owns its step and its compensation; the saga emerges from event subscriptions:

// Minimal event bus — in production this is Kafka, RabbitMQ, or SNS
const bus = {
  subs: {},
  on(event, fn) { (this.subs[event] ??= []).push(fn); },
  async emit(event, data) { for (const fn of this.subs[event] ?? []) await fn(data); },
};

// Hotel reacts to the flight reservation
bus.on("FlightReserved", async (e) => {
  try {
    await hotelService.reserve(e.bookingId);
    await bus.emit("HotelReserved", e);
  } catch {
    await bus.emit("BookingFailed", e);
  }
});

// Payment reacts to the hotel reservation
bus.on("HotelReserved", async (e) => {
  try {
    await paymentService.charge(e.bookingId);
    await bus.emit("PaymentCharged", e);
  } catch {
    await bus.emit("BookingFailed", e);
  }
});

// Compensations subscribe to the failure event
bus.on("BookingFailed", async (e) => {
  await hotelService.cancel(e.bookingId);
  await flightService.cancel(e.bookingId);
});

The saga recipe wires a full version of this to real services.

sequenceDiagram diagram: participant O as Orchestrator

Sagas are ACD, not ACID

Each local transaction commits immediately, so intermediate state is visible. Another operation can observe a half-finished flow — flight reserved, payment not attempted yet — and two sagas can fight over the same row. That loss of isolation is the real cost of the pattern:

  • Dirty reads: a query sees flight_reserved before the saga completes. Countermeasure: a status field that marks intent (BOOKING_PENDING) so readers know the state is provisional — a semantic lock.
  • Lost updates: two sagas write the same record and one silently wins. Countermeasure: version checks, or commutative updates (add/remove instead of overwrite).
  • Fuzzy reads: a report reads mid-saga state and totals that will change. Countermeasure: flag derived state as provisional until the saga completes, or re-read before acting.

Design the undo path first

A compensation is a new business operation, not a database undo: once reserve_flight commits, “un-reserving” means calling the cancel endpoint, and the canceled reservation stays in the log. Some steps can’t be undone at all — a sent email, a shipped package — so order the saga around the pivot step, the first action that can’t be compensated. Everything before the pivot must be compensable; everything after it only has to be safely retriable.

The orchestrator is stateful — persist it

The in-memory completed list in the demos loses everything if the process dies mid-saga. A production orchestrator writes each transition to a saga log — a table or event stream in the coordinator’s own store — and resumes from the last committed step on restart. Persisting the saga itself is what lets a three-day booking survive a deploy.

You don’t have to build this

  • Temporal / Cadence: durable workflows with compensation handlers, timers, and replay — the closest thing to batteries-included sagas.
  • Axon Framework, Eventuate Tram (Java): saga orchestration on top of messaging or event sourcing.
  • MassTransit (.NET): saga state machines with message correlation.
  • AWS Step Functions: serverless orchestration where Catch blocks act as compensations.
  • The saga recipe covers both styles; workflow engines take the same idea to durable execution.

Variants

VariantDescriptionUse Case
Orchestrated SagaCentral coordinator manages the flowComplex or branching flows that need visibility
Choreographed SagaEvent-driven, no central coordinatorShort linear flows; loose coupling
Parallel SagaIndependent steps run concurrentlyNon-dependent operations
Nested SagaA saga step triggers another sagaComplex domain decompositions

What Works

  • Design compensations first — write the undo before the action; if you can’t name the compensation, the step doesn’t belong in a saga
  • Order around the pivot step: compensable steps first, the irreversible step last, retriable steps after it
  • Idempotency keys on every step and compensation — retries, replays, and idempotent consumers will re-execute them
  • Persist saga state so a crashed coordinator resumes instead of forgetting committed steps
  • Time out every step: a missing response should trigger compensation, not wait forever
  • Retries inside the step for transient failures; compensate only when retries exhaust

Common Mistakes

  • Treating compensation as a true rollback — the committed data was already visible to other operations
  • Putting an irreversible step (send the email, charge without a refund API) early in the flow
  • Forgetting compensations for steps that have side effects — including the “harmless” step that fires a notification
  • Running saga steps without timeouts, letting a dead service stall the flow indefinitely
  • Assuming isolation: two sagas touching the same record need semantic locks or versioning
  • Keeping saga progress only in memory — the first crash mid-saga strands the flow

See Also

Frequently Asked Questions

What is the difference between Saga and 2PC?

2PC locks resources across all participants until commit — strong consistency, but blocking and unavailable on most NoSQL and HTTP-based stacks. A saga commits each step locally and undoes through compensation, trading isolation for availability.

How do I handle a compensation that also fails?

Retry it — compensations should be idempotent and safe to re-run. When retries exhaust, park the failed compensation on a dead-letter queue and alert an operator: a stuck compensation is inconsistent data that needs manual repair.

Orchestration or choreography — which should I choose?

Choreography for short, linear flows of two to four steps where loose coupling matters most. Orchestration when the flow branches, spans team boundaries, or needs one place to inspect, replay, and debug.

Can two sagas touch the same data at once?

Yes — sagas don't provide isolation. Guard the shared records with a semantic lock (a status field like BOOKING_PENDING), version checks, or commutative updates so a second saga waits or fails cleanly instead of clobbering the first.

Where does the saga state live?

In the coordinator's own store — a saga table or an event log, not in process memory. If the orchestrator restarts mid-saga it reloads the completed steps and resumes; durable workflow engines like Temporal persist this for you.