The Problem: One Business Transaction, Five Services, No ACID
A traveller books a trip. The flight, the hotel, the rental car, the airport transfer, and the travel-insurance policy all live in different services with different databases and different owners. From the traveller’s point of view, this is one transaction: either all five legs are reserved or the booking does not exist. From the database’s point of view, no atomic transaction can span five connection pools.
This is not an oversight. It is the structural consequence of splitting a monolith into microservices. ACID guarantees work inside one database, inside one connection, inside one trust boundary. The moment a unit of work crosses a service boundary — even a tiny one — the global ACID transaction breaks apart. Two-phase commit can hold multiple databases in lock-step, but it does not hold services in lock-step: a PREPARE on a third-party flight API is a best-effort asynchronous event, not a transactional vote.
The doubling-write problem
A naive fix writes the booking twice — once inside each service’s local transaction, once to a “global” denormalized store. Every write doubles, the second write can fail independently, and the two writes can race. The denormalization becomes the system of record, which means the source of truth is now split, which means every future query has to reconcile two timelines. This is a worse problem than the one the fix was trying to solve.
So the question the saga pattern answers is the pragmatic one: when a single ACID transaction is impossible, what is the smallest change to the problem that makes a single business outcome possible? The answer: break the business transaction into a sequence of local transactions, each of which can be undone, and accept that “undo” is itself a local transaction.
The pattern was first formalized by Hector Garcia-Molina and Kenneth Salem in their 1987 paper Sagas, in the context of long-running database transactions. The microservices-era revival — what this post treats — comes from Chris Richardson’s microservices.io and the working systems that have shipped it in anger: AWS Step Functions, Camunda, Temporal. The shape is forty years old. The applications are new.
Solution Preview: A Saga Is a Voyage With a Turnaround Plan
A voyage has legs. Each leg is an action with a destination — a flight, a hotel, a rental car — and each leg has a turnaround: the matching action that puts the trip back where it started if anything further down the line fails. The saga pattern makes that voyage structure the unit of business work.
Where the Bulkhead pattern isolates failure by partitioning resources into separate pools, the Saga pattern coordinates recovery across services that no longer share a single database. Bulkhead contains the blast; Saga writes the playbook for putting the pieces back together.
A saga is, formally, a sequence of local transactions , each paired with a compensating transaction . Forward progress happens left-to-right; on the first failure, the saga runs the compensations in reverse for every that already completed. The compensating is a real action — CancelFlight, RefundPayment, ReleaseSeat — not a database rollback. Sagas coordinate what database transactions cannot.
This post covers the two top-level variants — orchestration (a central coordinator tells each service what to do) and choreography (each service reacts to events on a wire) — plus three implementation shapes, a trade-off section, and a decision matrix. The related-patterns section at the end points forward to Circuit Breaker and the Outbox Pattern — the patterns every non-trivial saga turns out to need.
The Two Variants: Orchestration vs Choreography
Same shape, two logics of control.
Orchestrated saga. A single service — the orchestrator — knows the saga’s steps. It calls flight.reserve, then hotel.reserve, then car.reserve, in order. Each step returns success or failure to the orchestrator, which decides the next move. On any failure, the orchestrator emits the compensating calls in reverse order to the steps that already completed. The orchestrator persists a saga log: a per-saga append-only journal of which steps have completed and which have been compensated, so a crash can be recovered by replaying the journal.
Choreographed saga. There is no orchestrator. Each service emits events on a message broker (FlightReserved, HotelReserved) and listens for events from the other services. The saga’s forward progress is the path events follow through the system; compensations are services that listen for TripBookingFailed and react. The state of the saga lives in the broker’s offsets and each participant’s local store. There is no single log; each participant owns its own slice.
Without Saga vs With Saga (Problem Framing)
The monolith is fine. The microservices version is what happens when the monolith has been split for good reasons — independent deploys, team boundaries, scaling policies. The saga is not a worse transaction; it is a different shape of work, with different failure modes and a different operational surface.
Orchestration: A Directed Sequence
The orchestrator is the single owner of the saga’s state. Every step, every compensation, every retry, every timeout is its concern. The participant services know nothing of the broader saga — flight.reserve is the same call it would receive from any caller. This decoupling from the participant side is the orchestrator’s virtue.
Choreography: An Event Cascade
No central service owns anything. The saga’s forward progress is whatever sequence of events the broker delivers. The compensation flow is another sequence of events, going the other way. The brittleness is hidden in the cross-service event contracts: a TripBookingRequested event changes schema, and three downstream consumers need to be migrated in lock-step.
The 'who owns the saga?' question is the right test
Orchestrated sagas are owned by the orchestrator. Choreographed sagas are owned by every consumer that handles a related event — by the time the team’s fully drawn, the “owner” is whichever engineer happens to be awake when the broker goes down. Pick the variant whose ownership story you can sustain at 3 a.m.
Compensating Transactions: The Saga’s Real Work
The pattern’s name implies forward progress. The actual work is backward progress on demand. For every forward transaction , the saga must define a compensating transaction that semantically undoes — not a database rollback, an action.
In the canonical microservices.io travel-booking example, the saga is:
| Step | Forward () | Compensation () |
|---|---|---|
| 1 | ReserveFlight | CancelFlightReservation |
| 2 | ReserveHotel | CancelHotelReservation |
| 3 | ReserveCar | CancelCarReservation |
| 4 | ReserveAirportTransfer | CancelAirportTransfer |
| 5 | ActivateTravelInsurance | CancelTravelInsurance |
The compensations are not DELETE FROM booking WHERE id = ?. They are business actions that undo the business effect of the forward step: cancel the booking with the airline’s API, release the room back to the hotel’s inventory, mark the car available again. A compensation can itself fail — which is the saga’s most-overlooked failure mode.
The Duelling-Compensations Problem
Imagine CancelFlightReservation itself fails. The airline’s API returns 503 just as the saga is trying to clean up. Now the saga is stuck in a half-compensated state: the flight reservation is still live in the airline’s system (money committed), the hotel and car are cancelled (good), and the orchestrator (or choreographed consumer) is looping on the compensation.
The patterns that address this are:
- Idempotent compensations. Every compensation carries an
idempotency_keyderived from(saga_id, step_id, "compensation"). The first call has effect; subsequent retries with the same key are no-ops. The compensation’s effect is the same whether it succeeds on the first call or the hundred-and-first. - Retry-with-backoff at the saga level. The orchestrator (or the choreographed consumer) retries the failed compensation on a schedule — exponential backoff capped at a maximum interval, repeating until the compensation succeeds or a human-driven timeout aborts the saga.
- A saga log for the saga. When even the compensation log is unreliable, escalate to a meta-saga that monitors the primary saga’s stuck compensations and applies operational interventions (manual credit, partial refund), surfacing them to a human operator for the cases automation cannot resolve.
- Idempotency at the participant. The participant service is the last line of defence: if it receives
CancelFlightReservationtwice with the same idempotency key, the second call is a no-op. Sagas cannot work without participant-level idempotency.
The forward step that has no compensation is a design smell
If you find a forward step in your saga that has no clean compensation, the step probably does not belong in the saga — the failure mode for “we cannot undo this” is not “compensate harder,” it is “do not perform the irreversible action until irreversibility is no longer a problem.” Real-world example: an ActivateInsurancePolicy step has a compensation (CancelPolicy) but only if activation has a pending window. Past the window, there is no compensation. The fix is to keep activation inside a managed-delay state machine, not to write a clever saga around the irreversible case.
The partial-compensation edge case — refund minus a cancellation fee, partial reversal, side-effect-irreversible steps — is a rich topic of its own, with failure modes that do not fit cleanly into the “full undo” model above. A follow-up post in this series will cover compensation semantics in depth — see the related-patterns section at the end for the slot. The model’s full-undo assumption is the load-bearing one for understanding saga implementation today; partial compensations are a strict superset that require a richer state model.
Implementation Strategies: Three Shapes
The pattern is shape, not product. The three shapes below are what you will see in production code — Temporal, Camunda, AWS Step Functions all live under the third shape; the first two are hand-rolled.
Shape 1: Hand-Rolled Orchestrator
The simplest production-ready implementation is an orchestrator service that runs the saga’s step graph against real participant services. The saga log is a table in the orchestrator’s database. Step failures trigger backward-running compensations.
// saga/orchestrator.ts — a 90-line saga orchestrator with retries,
// backoff, idempotency, and a saga log. Production code adds DB
// persistence for the saga_log; the shape here is the load-bearing
// part, not the wire format.
type SagaStep = {
readonly id: string;
execute: () => Promise<{ ok: true; result: unknown } | { ok: false; reason: string }>;
compensate: () => Promise<{ ok: true } | { ok: false; reason: string }>;
};
type SagaLog = {
saga_id: string;
completed: string[]; // step ids in completion order
compensations_run: string[];
state: 'started' | 'forward' | 'compensating' | 'completed' | 'failed';
};
async function runSaga(
steps: readonly SagaStep[],
log: SagaLog,
retry: { maxAttempts: number; backoffMs: (n: number) => number } = {
maxAttempts: 5,
backoffMs: n => Math.min(30_000, 500 * 2 ** n),
},
): Promise<{ ok: true } | { ok: false; failedStep: string }> {
for (const step of steps) {
let attempt = 0;
let result: Awaited<ReturnType<SagaStep['execute']>>;
for (;;) {
result = await step.execute();
if (result.ok) break;
attempt++;
if (attempt >= retry.maxAttempts) {
// Mark for compensation and fall through.
log.state = 'compensating';
await compensateInReverse(steps, log, retry);
return { ok: false, failedStep: step.id };
}
await new Promise(r => setTimeout(r, retry.backoffMs(attempt)));
}
log.completed.push(step.id);
}
log.state = 'completed';
return { ok: true };
}
async function compensateInReverse(
steps: readonly SagaStep[],
log: SagaLog,
retry: SagaParameters['retry'],
): Promise<void> {
for (const step of [...log.completed].reverse().map(id => steps.find(s => s.id === id)!)) {
let attempt = 0;
for (;;) {
const r = await step.compensate();
if (r.ok) { log.compensations_run.push(step.id); break; }
attempt++;
if (attempt >= retry.maxAttempts) {
log.state = 'failed';
return; // saga-for-the-saga
}
await new Promise(r => setTimeout(r, retry.backoffMs(attempt)));
}
}
log.state = 'failed';
}
type SagaParameters = Parameters<typeof runSaga>[2];
// Usage:
const steps: SagaStep[] = [
{ id: 'flight', execute: () => flight.reserve(...), compensate: () => flight.cancel(...) },
{ id: 'hotel', execute: () => hotel.reserve(...), compensate: () => hotel.cancel(...) },
{ id: 'car', execute: () => car.reserve(...), compensate: () => car.cancel(...) },
];
const log: SagaLog = { saga_id: 'trip-001', completed: [], compensations_run: [], state: 'started' };
const result = await runSaga(steps, log);
The shape is: a step iterator, a retry/backoff loop per step, a compensation loop driven by the saga log, and a state machine on the log itself. A real implementation moves log into a database row with optimistic concurrency; this code is the conceptual scaffold, not the production file. Add saga-log-persistence, error-out to a dead-letter queue, and a meta-saga for stuck compensations, and you have a hand-rolled orchestrator that ships.
Shape 2: Event-Driven Choreography
The choreography shape is no orchestrator. Each participant service owns its slice of the saga and reacts to events on a message broker. The state of the saga is distributed.
// flight-service.ts — the flight participant in a choreographed
// booking saga. Subscribes to TripBookingRequested; emits
// FlightReserved or FlightReservationFailed.
import { EventEmitter } from 'node:events';
type TripBookingRequested = {
saga_id: string;
leg_id: string;
flight_offer: { airline: string; flight_no: string; iso: string };
trace: string; // correlation id for distributed tracing
};
type SagaEvent =
| { type: 'TripBookingRequested'; payload: TripBookingRequested }
| { type: 'FlightReserved'; payload: { saga_id: string; conf: string } }
| { type: 'FlightReservationFailed'; payload: { saga_id: string; reason: string } }
| { type: 'TripBookingCancelled'; payload: { saga_id: string } };
const bus = new EventEmitter();
bus.on('TripBookingRequested', async (evt) => {
if (evt.payload.leg_id !== 'flight') return;
try {
const conf = await flightProvider.reserve(evt.payload.flight_offer, evt.payload.trace);
bus.emit('saga', { type: 'FlightReserved', payload: { saga_id: evt.payload.saga_id, conf } } satisfies SagaEvent);
} catch (err) {
bus.emit('saga', {
type: 'FlightReservationFailed',
payload: { saga_id: evt.payload.saga_id, reason: String(err) },
} satisfies SagaEvent);
}
});
bus.on('TripBookingCancelled', async (evt) => {
const log = await readCompensationLog(evt.payload.saga_id);
if (!log.flightReserved) return;
try {
await flightProvider.cancel(log.conf, evt.payload.saga_id /* idempotency key */);
bus.emit('saga', { type: 'FlightReservationCancelled', payload: { saga_id: evt.payload.saga_id } } satisfies SagaEvent);
} catch (err) {
// Retry handled by the broker's redelivery + dead-letter queue.
throw err;
}
});
emit('FlightReserved', { saga_id, conf });
The load-bearing shape here is: events flow forward, then compensation events flow backward. Each consumer has its own idempotency story. The “saga state” is whatever any one consumer can derive from the events it has seen; there is no global view.
// hotel-service.ts — the hotel participant reacting to FlightReserved
// and CarReservationFailed. This is what a choreographed saga looks
// like from inside the second step.
bus.on('FlightReserved', async (evt) => {
const saga = await readSaga(evt.payload.saga_id);
if (saga.legs.includes('hotel') && !saga.hotelReserved) {
try {
const conf = await hotelProvider.reserve(saga.hotelOffer, evt.payload.saga_id);
bus.emit('saga', { type: 'HotelReserved', payload: { saga_id: evt.payload.saga_id, conf } } satisfies SagaEvent);
} catch (err) {
bus.emit('TripBookingCancelled', { payload: { saga_id: evt.payload.saga_id, reason: String(err) } } satisfies never as never as any);
// In production: also emit FlightReservationFailed so the
// flight compensator fires.
}
}
});
The ergonomic cost of choreography shows up exactly here: every participant has to know which events to react to and which to ignore, and any new step in the saga requires changing every other consumer’s logic. The orchestrator expresses this as a list; choreography expresses it as a graph.
Shape 3: Workflow Engine (Temporal, Camunda, Step Functions)
A workflow engine externalizes the orchestrator. The engine persists the saga log, schedules retries, and offers observability for running sagas out of the box. The team writes the saga’s logic; the engine runs it.
// saga.workflow.ts — a Temporal workflow for the same booking saga.
// Note: the body is a plain async function; Temporal handles the
// retries, the saga log, the timeouts, and the compensation calls
// automatically when the workflow throws.
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as acts from './activities';
const { reserveFlight, cancelFlight, reserveHotel, cancelHotel, reserveCar, cancelCar } =
proxyActivities<typeof acts>({
startToCloseTimeout: '30s',
retry: { maximumAttempts: 5, backoffCoefficient: 2 },
});
export async function bookTripSaga(legs: TripLegs): Promise<Trip> {
const conf: Partial<Trip> = {};
try {
conf.flight = await reserveFlight(legs.flight);
conf.hotel = await reserveHotel(legs.hotel);
conf.car = await reserveCar(legs.car);
return conf as Trip;
} catch (err) {
// Temporal's Saga compensation API handles the reverse calls.
// In compensation mode, fail() is not retried — the engine
// records the compensation as a saga step.
if (ApplicationFailure.hasType(err, 'CarReservationFailed')) {
await Promise.allSettled([
conf.hotel && cancelHotel(conf.hotel),
conf.flight && cancelFlight(conf.flight),
]);
}
throw err;
}
}
This is the same shape as Shape 1, but the orchestrator’s “saga log” is the Temporal state store, the “retry” is the activity retry policy, and the “compensation” is the workflow’s catch block. Production teams reach for engines when the hand-rolled version’s failure modes (saga-log persistence, observability, long-running sagas that survive deploys) start costing more than the engine itself.
| Shape | When to reach for it | Trade-off |
|---|---|---|
| Hand-rolled orchestrator | You have one team, simple workflows, and want to read every line of the saga code itself | You own the saga-log persistence, observability, and recovery story |
| Choreography | Participants are owned by separate teams; the saga is loosely-coupled by design | Observability cost; cross-team event-schema coordination |
| Workflow engine | Long-running sagas (minutes to days), high cardinality of saga variants, need durable execution that survives deploys | Adds a vendor / self-host dependency; engine-specific concepts |
Trade-Offs and Observability
The saga pattern is not free. Three trade-offs and three observability surfaces.
Trade-Offs
- Operational complexity. A saga has three things to monitor per step (forward success, compensation success, stuck-compensation), a retry policy per step, and a saga log that has to survive process restarts and database migrations. A monolith has a transaction; a saga has an operating procedure.
- Eventual consistency window. Between the moment a saga’s forward step succeeds and the moment every downstream service sees the resulting state, the saga is inconsistent. The window can be milliseconds (synchronous orchestrator, fast broker) or minutes (choreography, manual interventions). The business has to reason about queries that span the window — “is the flight booked?” has a different answer depending on whether the hotel service has seen the saga yet.
- Coupling trade-off. Orchestration couples the orchestrator to every participant’s contract; choreography couples every participant to every other participant’s event schemas. Pick which side of that trade you want to be on.
Observability
The three surfaces that make or break a saga in production:
- Idempotency keys per step. Every forward call and every compensation carries
idempotency_key = hash(saga_id, step_id, "compensation"|"forward"). Search by idempotency key shows the full saga state, even if the saga log is partial. - Correlation IDs. Every log line, trace, and metric for a saga step carries
saga_idandtrace_id. A single query should be able to reconstruct the saga’s history from logs and traces alone, without consulting the saga log. - Saga-state dashboards. Count sagas by state (
started,forward,compensating,completed,failed,stuck) over time. Stuck-compensations are the most important metric — they are the ones that need a human.
If you cannot see stuck-saga-state count in your monitoring, you do not have saga observability
A saga log that is not exposed to a runbook is invisible. The simplest dashboard is two counters: forward-progress (started → completed over the last hour) and compensation-progress (compensating → compensated over the last hour). When the second number spikes, you have a problem; when the first number plateaus, you have a worse one.
The Decision Matrix
Reach for the variant whose shape fits the problem. Eight rules, each one a test.
Decision Matrix — orchestration vs choreography, eight rules
Rule 1 — Reach for orchestration when the saga’s logic is owned by one team. If a single team owns the workflow’s steps and every participant service, an orchestrator expresses the saga as a list and the team can read it. The coupling cost is local; the observability is one place.
Rule 2 — Reach for choreography when participants are owned by many teams. If the flight service, the hotel service, and the car service are owned by separate teams with separate deploy cadences, no orchestrator belongs to any one of them. The choreography’s event contracts are the only honest API surface.
Rule 3 — Reach for orchestration when the saga’s state must be globally observable. If you need to answer “how many sagas are stuck compensating right now?” with one dashboard query, orchestration’s saga log gives you that for free. Choreography gives you a distributed assembly problem.
Rule 4 — Reach for choreography when the saga has no natural “director” service. Some sagas have no obvious orchestrator candidate — adding one is a service you would have to write and operate. Choreography is cheaper to start and lets the participants evolve independently.
Rule 5 — Reach for a workflow engine when sagas run for hours or days. Hand-rolled orchestrators do not survive deploys well. Engines (Temporal, Camunda, Step Functions) persist the saga log through deploys, cluster failures, and operator mistakes; the engine cost is paid back in incidents you do not have.
Rule 6 — Reach for choreography only if your events carry full state.
A choreographed saga’s FlightReserved event needs enough information for the hotel service to do its work without calling back to the flight service. Choreography with chatty callbacks is orchestration in disguise, and worse.
Rule 7 — Reach for orchestration when compensations are common. If the compensation path runs more than the forward path in production, the cost of “every consumer reacts to events” becomes a tax on every change. Orchestration makes the compensation path a single function call.
Rule 8 — Reach for choreography when the team has invested in event-sourcing. Choreography is natural on top of an event-sourced system, where every state change is already an event and every consumer reads from the same log. Choreography on top of stateful databases without events is awkward and slow.
Rule 0 (load-bearing) — Reach for neither when a single ACID transaction fits. If all five legs of the booking can live in one database with one connection, the saga is over-engineering. Sagas buy you a capability; do not pay for it when you do not need it.
Read it once, save it in the team’s wiki, and the next saga feature you scope will find its variant on the first read.
A Two-Axis Picture of the Decision
The flowchart reads top-down for the first three questions, then defaults to “either works” if no clear signal emerges. The single-team signal is the strongest; the saga-duration signal overrides observability, because no hand-rolled orchestrator survives a multi-day saga through deploys.
Conclusion
The saga pattern is what coordinated work looks like when ACID cannot cross service boundaries. The two variants — orchestration and choreography — are the same shape with two logics of control: one says “do this, then this, then this”; the other says “react to this, then react to that.” Both carry the same load: forward transactions paired with compensations, persisted in a saga log you can recover from, observable through correlation IDs and stuck-saga counters, and fronted by a decision procedure that picks the variant whose shape matches your constraints.
Together with the Bulkhead pattern — which isolates the failure — the saga pattern coordinates the recovery. The two patterns are halves of a single answer: Bulkhead contains the blast, Saga writes the playbook. A follow-up post in this series will cover compensation semantics in depth, including partial compensations and side-effect-irreversible steps; look for the saga-compensation-semantics post in this series.
Related Patterns
Every non-trivial saga turns out to need at least two more patterns from this series. Bulkhead is the architectural prerequisite; Circuit Breaker and Outbox Pattern are the operational primitives.
- Bulkhead Pattern — back-reference. Where Bulkhead isolates failure, Saga coordinates recovery. Pair them: Bulkhead inside each saga step’s thread pool; Saga across the saga’s services.
- Circuit Breaker Pattern — every saga step that calls an external service needs a circuit breaker, so a broken participant does not drag the saga’s retries into a death spiral.
- Outbox Pattern (forward reference) — every choreographed saga’s event publish needs the Outbox so the local transaction and the event publish cannot desynchronize.
- saga-compensation-semantics (forward reference) — the partial-compensation, refund-window, and side-effect-irreversible cases the canonical “full undo” model does not address. Reserved for a separate post.
Comments
Please accept the "Functionality" cookie category to view and post comments.
Comments failed to load. You can try again or view the discussion directly on GitHub.
View on GitHub