System Design (High-Level Design) Module 11 – Distributed transactions and workflows
Sagas: orchestration, choreography, compensation
How a saga splits a business transaction into local steps with compensations, when to orchestrate or choreograph it, and where the pivot goes.
What you will learn
- Model a multi-service business transaction as a saga of local steps and compensations
- Choose orchestration or choreography for a saga and justify the choice
- Design compensations and place the pivot step
- Prevent duplicate effects when saga steps and compensations are repeated after a crash
Before you start
What you’ll learn · Before you start
What you will learn
- Model a multi-service business transaction as a saga of local steps and compensations
- Choose orchestration or choreography for a saga and justify the choice
- Design compensations and place the pivot step
- Prevent duplicate effects when saga steps and compensations are repeated after a crash
Before you start
On this page
An online order touches four services, and the card is declined after the stock has been reserved: two of its six steps have already committed in other databases, and nothing can roll them back. A saga: A business transaction across several services, run as a sequence of local transactions, each paired with a compensating transaction that undoes its effect if a later step fails. carries out such a business transaction as a sequence of local transactions, each committed in one service’s own database, and pairs every step with a compensating transaction: A new transaction with the opposite business effect of a step that has already committed, such as releasing a reservation or issuing a refund. It does not restore old values; it leaves the business in an acceptable state. that undoes its effect if a later step fails. Either every step runs, or the steps that ran are followed by their compensations in reverse order.
Nothing holds locks across services, so a saga never blocks the way two-phase commit can. The price is that other requests can see its half-finished state, and that an undo is a new action, a refund or a cancellation, not a rollback. The steps are coordinated in one of two ways: orchestration (saga): Running a saga from one coordinator that keeps the saga's state, sends each service a command and decides the next step from the reply., where one coordinator tells each service what to do, or choreography (saga): Running a saga without a coordinator: each service reacts to the events of the step before and publishes its own, so the flow is the sum of the services' subscriptions., where each service reacts to the events of the one before.
A saga commits each step and undoes it with a compensation
Garcia-Molina and Salem named the idea in 1987 for long-lived transactions that would otherwise hold locks for hours (Garcia-Molina and Salem). An online order that touches orders, stock, payments and shipping becomes six local transactions:
The order saga: steps, compensations and the pivot
Text description of the diagram
The diagram has two boxes, one above the other, and the steps are numbered in the order they run.
The upper box is labelled "Up to the pivot: a failure undoes every step done, newest first". It holds four steps:
- Create order; undo: cancel the order.
- Reserve stock; undo: release the stock.
- Authorise payment, which places a hold; undo: void the authorisation.
- Capture payment, the pivot: once it succeeds, there is no way back.
The lower box is labelled "After the pivot: a failure is retried, never undone". It holds two steps:
- Book courier, retried until it succeeds.
- Send confirmation, retried until it succeeds.
Step through the order of the opening, the one whose card is declined at step 3:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Create the order. The order service commits order 1001 as PENDING in its own database. Anyone who reads it now sees a pending order.
- Reserve the stock. The stock service commits a reservation of one unit. Other buyers see one unit fewer at once, long before the payment.
- The card is declined. The payment service refuses the authorisation, so step 3 commits nothing. Steps 1 and 2 are already committed in other databases, and nothing can roll them back.
- Undo step 2 first. The saga runs the compensations of the steps that succeeded, newest first. Releasing the reservation puts the unit back for other buyers.
- Then undo step 1. Cancelling the order is a new transaction with the opposite business effect, so the order row stays, as CANCELLED, instead of disappearing.
- The pivot was never reached. Steps 4 to 6 never ran, so the card was never charged and there is nothing to refund. The saga ends with every step either undone or never started.
Each step commits on its own and is visible at once: other buyers see the reserved unit gone before the payment step has started. When the card is declined, nothing is rolled back; the saga runs the compensations of the steps that succeeded, newest first, releasing the stock and then cancelling the order. The paper’s own example is an airline booking whose compensation cancels a seat by subtracting one from the reservations count instead of writing back the old count, because other bookings may have changed it in the meantime.
Checkpoint
Check: Local steps and compensations
Three kinds of step decide where the pivot goes
Azure’s description of the pattern sorts saga steps into three kinds (Azure):
- A compensable step has an undo: a later transaction that cancels its business effect.
- The pivot (saga): The step of a saga after which it can no longer turn back, such as capturing a payment. The steps before it can be compensated; the steps after it are retried until they succeed. is the point of no return: once it succeeds, the saga must finish.
- A retryable step comes after the pivot and is idempotent, so it is repeated until it succeeds.
The rule that follows is about order. Put the steps most likely to fail before the pivot, where they cost a cheap compensation, and put the irreversible step, here capturing the payment, after every check that might reject the order. A saga that charges the card first turns every out-of-stock item into a refund. And remember that compensation is not rollback: a sent email stays sent, and the best a compensation can do is send a correction.
The stepper runs the order saga once with no failure and once for each step that has a failure reason in its table, and prints every step and compensation. Give another step a reason, or clear one, and run it again:
Python 3.14 · main.py
SAGA = [ # (step, kind, compensation, why a run fails at this step)
("create order", "compensable", "cancel order", ""),
("reserve stock", "compensable", "release stock", "out of stock"),
("authorise payment", "compensable", "void the authorisation", "card declined"),
("capture payment", "pivot", "", "capture refused"),
("book courier", "retryable", "", "courier unavailable"),
("send confirmation", "retryable", "", ""),
]
def run(failing: str) -> str:
done: list[str] = []
for step, kind, compensation, why in SAGA:
if step == failing and kind != "retryable":
print(f" failed {step}: {why}")
for undo in reversed(done): # compensations run newest first
print(f" undo {undo}")
return "CANCELLED"
if step == failing: # after the pivot: retried until it works
for attempt in (1, 2):
print(f" retry {step}: attempt {attempt} failed ({why})")
print(
f" done {step}" + (" (pivot: no way back)" if kind == "pivot" else "")
)
if compensation:
done.append(compensation)
return "CONFIRMED"
print("One run per failing step")
for failing in ["", *(step for step, _, _, why in SAGA if why)]:
print()
print(f"'{failing}' fails" if failing else "No step fails")
print(f" result: order {run(failing)}") In Python: The last column of each step says why a run fails there, so the runs to make are the steps with a reason; reversed(done) walks the compensations newest first without copying the list.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const saga = [
// [step, kind, compensation, why a run fails at this step]
["create order", "compensable", "cancel order", ""],
["reserve stock", "compensable", "release stock", "out of stock"],
["authorise payment", "compensable", "void the authorisation", "card declined"],
["capture payment", "pivot", "", "capture refused"],
["book courier", "retryable", "", "courier unavailable"],
["send confirmation", "retryable", "", ""],
];
function run(failing) {
const done = [];
for (const [step, kind, compensation, why] of saga) {
if (step === failing && kind !== "retryable") {
console.log(` failed ${step}: ${why}`);
// compensations run newest first
for (const undo of [...done].reverse()) console.log(` undo ${undo}`);
return "CANCELLED";
}
if (step === failing) {
// after the pivot: retried until it works
for (const attempt of [1, 2])
console.log(` retry ${step}: attempt ${attempt} failed (${why})`);
}
console.log(` done ${step}${kind === "pivot" ? " (pivot: no way back)" : ""}`);
if (compensation) done.push(compensation);
}
return "CONFIRMED";
}
console.log("One run per failing step");
for (const failing of ["", ...saga.filter(([, , , why]) => why).map(([step]) => step)]) {
console.log("");
console.log(failing ? `'${failing}' fails` : "No step fails");
console.log(` result: order ${run(failing)}`);
} In JavaScript: saga.filter keeps the steps that have a failure reason, in saga order, and [...done].reverse() reverses a copy, so done itself is never changed.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.ArrayList;
import java.util.List;
public class Main {
record Step(String name, String kind, String compensation, String why) {}
// why: why a run fails at this step
static final List<Step> SAGA =
List.of(
new Step("create order", "compensable", "cancel order", ""),
new Step("reserve stock", "compensable", "release stock", "out of stock"),
new Step("authorise payment", "compensable", "void the authorisation", "card declined"),
new Step("capture payment", "pivot", "", "capture refused"),
new Step("book courier", "retryable", "", "courier unavailable"),
new Step("send confirmation", "retryable", "", ""));
static String run(String failing) {
List<String> done = new ArrayList<>();
for (Step step : SAGA) {
boolean failed = step.name().equals(failing);
if (failed && !step.kind().equals("retryable")) {
System.out.println(" failed " + step.name() + ": " + step.why());
// compensations run newest first
for (int i = done.size() - 1; i >= 0; i--) {
System.out.println(" undo " + done.get(i));
}
return "CANCELLED";
}
if (failed) {
// after the pivot: retried until it works
for (int attempt = 1; attempt <= 2; attempt++) {
System.out.println(
" retry " + step.name() + ": attempt " + attempt + " failed (" + step.why() + ")");
}
}
String pivot = step.kind().equals("pivot") ? " (pivot: no way back)" : "";
System.out.println(" done " + step.name() + pivot);
if (!step.compensation().isEmpty()) {
done.add(step.compensation());
}
}
return "CONFIRMED";
}
public static void main(String[] args) {
System.out.println("One run per failing step");
List<String> cases = new ArrayList<>(List.of(""));
SAGA.stream().filter(s -> !s.why().isEmpty()).forEach(s -> cases.add(s.name()));
for (String failing : cases) {
System.out.println();
System.out.println(failing.isEmpty() ? "No step fails" : "'" + failing + "' fails");
System.out.println(" result: order " + run(failing));
}
}
} In Java: Each step is a record, and a stream collects the failing runs; the compensations are read backwards by index, which also compiles on Java 17.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <print>
#include <string_view>
#include <vector>
enum class Kind { compensable, pivot, retryable };
struct Step {
std::string_view name;
Kind kind;
std::string_view compensation;
std::string_view why; // why a run fails at this step
};
constexpr Step SAGA[] = {
{"create order", Kind::compensable, "cancel order", ""},
{"reserve stock", Kind::compensable, "release stock", "out of stock"},
{"authorise payment", Kind::compensable, "void the authorisation",
"card declined"},
{"capture payment", Kind::pivot, "", "capture refused"},
{"book courier", Kind::retryable, "", "courier unavailable"},
{"send confirmation", Kind::retryable, "", ""}};
std::string_view run(std::string_view failing) {
std::vector<std::string_view> done;
for (const Step &step : SAGA) {
const bool failed = step.name == failing;
if (failed && step.kind != Kind::retryable) {
std::println(" failed {}: {}", step.name, step.why);
// compensations run newest first
for (auto undo = done.rbegin(); undo != done.rend(); ++undo) {
std::println(" undo {}", *undo);
}
return "CANCELLED";
}
if (failed) { // after the pivot: retried until it works
for (int attempt = 1; attempt <= 2; attempt++) {
std::println(" retry {}: attempt {} failed ({})", step.name, attempt,
step.why);
}
}
const bool pivot = step.kind == Kind::pivot;
std::println(" done {}{}", step.name,
pivot ? " (pivot: no way back)" : "");
if (!step.compensation.empty()) {
done.push_back(step.compensation);
}
}
return "CONFIRMED";
}
int main() {
std::println("One run per failing step\n\nNo step fails");
std::println(" result: order {}", run(""));
for (const Step &step : SAGA) {
if (!step.why.empty()) {
std::println("\n'{}' fails", step.name);
std::println(" result: order {}", run(step.name));
}
}
} In C++: done holds string_views into the constexpr SAGA table, and rbegin() to rend() walks it backwards, newest compensation first.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// Why: why a run fails at this step.
Step[] saga =
[
new("create order", "compensable", "cancel order", ""),
new("reserve stock", "compensable", "release stock", "out of stock"),
new("authorise payment", "compensable", "void the authorisation", "card declined"),
new("capture payment", "pivot", "", "capture refused"),
new("book courier", "retryable", "", "courier unavailable"),
new("send confirmation", "retryable", "", ""),
];
Console.WriteLine("One run per failing step");
foreach (string failing in saga.Where(s => s.Why != "").Select(s => s.Name).Prepend(""))
{
Console.WriteLine();
Console.WriteLine(failing == "" ? "No step fails" : $"'{failing}' fails");
Console.WriteLine($" result: order {Run(saga, failing)}");
}
static string Run(Step[] saga, string failing)
{
var done = new List<string>();
foreach (var (step, kind, compensation, why) in saga)
{
if (step == failing && kind != "retryable")
{
Console.WriteLine($" failed {step}: {why}");
// compensations run newest first
for (int i = done.Count - 1; i >= 0; i--)
{
Console.WriteLine($" undo {done[i]}");
}
return "CANCELLED";
}
if (step == failing)
{
// after the pivot: retried until it works
for (int attempt = 1; attempt <= 2; attempt++)
{
Console.WriteLine($" retry {step}: attempt {attempt} failed ({why})");
}
}
Console.WriteLine($" done {step}{(kind == "pivot" ? " (pivot: no way back)" : "")}");
if (compensation != "")
{
done.Add(compensation);
}
}
return "CONFIRMED";
}
record Step(string Name, string Kind, string Compensation, string Why); In C#: Step is a positional record that foreach deconstructs, and Where, Select and Prepend("") put the run with no failure before the failing ones.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <string.h>
enum kind { COMPENSABLE, PIVOT, RETRYABLE };
struct step {
const char *name;
enum kind kind;
const char *compensation;
const char *why; // why a run fails at this step
};
static const struct step SAGA[] = {
{"create order", COMPENSABLE, "cancel order", ""},
{"reserve stock", COMPENSABLE, "release stock", "out of stock"},
{"authorise payment", COMPENSABLE, "void the authorisation",
"card declined"},
{"capture payment", PIVOT, "", "capture refused"},
{"book courier", RETRYABLE, "", "courier unavailable"},
{"send confirmation", RETRYABLE, "", ""},
};
enum { STEPS = sizeof SAGA / sizeof SAGA[0] };
static const char *run(const char *failing) {
const char *done[STEPS];
int count = 0;
for (int i = 0; i < STEPS; i++) {
const struct step *step = &SAGA[i];
const bool failed = strcmp(step->name, failing) == 0;
if (failed && step->kind != RETRYABLE) {
printf(" failed %s: %s\n", step->name, step->why);
// compensations run newest first
while (count > 0) {
printf(" undo %s\n", done[--count]);
}
return "CANCELLED";
}
if (failed) { // after the pivot: retried until it works
for (int attempt = 1; attempt <= 2; attempt++) {
printf(" retry %s: attempt %d failed (%s)\n", step->name, attempt,
step->why);
}
}
printf(" done %s%s\n", step->name,
step->kind == PIVOT ? " (pivot: no way back)" : "");
if (step->compensation[0] != '\0') {
done[count++] = step->compensation;
}
}
return "CONFIRMED";
}
int main(void) {
printf("One run per failing step\n\nNo step fails\n");
printf(" result: order %s\n", run(""));
for (int i = 0; i < STEPS; i++) {
if (SAGA[i].why[0] != '\0') {
printf("\n'%s' fails\n", SAGA[i].name);
printf(" result: order %s\n", run(SAGA[i].name));
}
}
} In C: done is a stack of pointers into the static SAGA table: a compensation is pushed with done[count++] and popped by the undo loop with done[--count].
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
/** Each step: [name, kind, compensation, why a run fails at this step] */
const SAGA = [
['create order', 'compensable', 'cancel order', ''],
['reserve stock', 'compensable', 'release stock', 'out of stock'],
['authorise payment', 'compensable', 'void the authorisation', 'card declined'],
['capture payment', 'pivot', '', 'capture refused'],
['book courier', 'retryable', '', 'courier unavailable'],
['send confirmation', 'retryable', '', ''],
];
function run(string $failing): string
{
$done = [];
foreach (SAGA as [$step, $kind, $compensation, $why]) {
if ($step === $failing && $kind !== 'retryable') {
echo " failed {$step}: {$why}\n";
// compensations run newest first
foreach (array_reverse($done) as $undo) {
echo " undo {$undo}\n";
}
return 'CANCELLED';
}
if ($step === $failing) {
// after the pivot: retried until it works
foreach ([1, 2] as $attempt) {
echo " retry {$step}: attempt {$attempt} failed ({$why})\n";
}
}
echo " done {$step}", $kind === 'pivot' ? ' (pivot: no way back)' : '', "\n";
if ($compensation !== '') {
$done[] = $compensation;
}
}
return 'CONFIRMED';
}
echo "One run per failing step\n";
$failing = array_column(array_filter(SAGA, fn(array $step): bool => $step[3] !== ''), 0);
foreach (['', ...$failing] as $name) {
echo "\n", $name === '' ? 'No step fails' : "'{$name}' fails", "\n";
$result = run($name);
echo " result: order {$result}\n";
} In PHP: array_filter keeps the steps that have a failure reason and array_column takes their names; each step unpacks as [$step, $kind, $compensation, $why].
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
#[derive(PartialEq)]
enum Kind {
Compensable,
Pivot,
Retryable,
}
// (step, kind, compensation, why a run fails at this step)
const SAGA: [(&str, Kind, &str, &str); 6] = [
("create order", Kind::Compensable, "cancel order", ""),
(
"reserve stock",
Kind::Compensable,
"release stock",
"out of stock",
),
(
"authorise payment",
Kind::Compensable,
"void the authorisation",
"card declined",
),
("capture payment", Kind::Pivot, "", "capture refused"),
("book courier", Kind::Retryable, "", "courier unavailable"),
("send confirmation", Kind::Retryable, "", ""),
];
fn run(failing: &str) -> &'static str {
let mut done = Vec::new();
for (step, kind, compensation, why) in &SAGA {
if *step == failing && *kind != Kind::Retryable {
println!(" failed {step}: {why}");
// compensations run newest first
for undo in done.iter().rev() {
println!(" undo {undo}");
}
return "CANCELLED";
}
if *step == failing {
// after the pivot: retried until it works
for attempt in 1..=2 {
println!(" retry {step}: attempt {attempt} failed ({why})");
}
}
let pivot = if *kind == Kind::Pivot {
" (pivot: no way back)"
} else {
""
};
println!(" done {step}{pivot}");
if !compensation.is_empty() {
done.push(*compensation);
}
}
"CONFIRMED"
}
fn main() {
println!("One run per failing step");
let failing = SAGA
.iter()
.filter(|step| !step.3.is_empty())
.map(|step| step.0);
for name in std::iter::once("").chain(failing) {
println!();
if name.is_empty() {
println!("No step fails");
} else {
println!("'{name}' fails");
}
println!(" result: order {}", run(name));
}
} In Rust: iter().rev() walks done backwards without copying it, and the failing runs are an iterator chained after the run with no failure.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
type Kind = "compensable" | "pivot" | "retryable";
// [step, kind, compensation, why a run fails at this step]
const saga: [string, Kind, string, string][] = [
["create order", "compensable", "cancel order", ""],
["reserve stock", "compensable", "release stock", "out of stock"],
["authorise payment", "compensable", "void the authorisation", "card declined"],
["capture payment", "pivot", "", "capture refused"],
["book courier", "retryable", "", "courier unavailable"],
["send confirmation", "retryable", "", ""],
];
function run(failing: string): string {
const done: string[] = [];
for (const [step, kind, compensation, why] of saga) {
if (step === failing && kind !== "retryable") {
console.log(` failed ${step}: ${why}`);
// compensations run newest first
for (const undo of [...done].reverse()) console.log(` undo ${undo}`);
return "CANCELLED";
}
if (step === failing) {
// after the pivot: retried until it works
for (const attempt of [1, 2])
console.log(` retry ${step}: attempt ${attempt} failed (${why})`);
}
console.log(` done ${step}${kind === "pivot" ? " (pivot: no way back)" : ""}`);
if (compensation) done.push(compensation);
}
return "CONFIRMED";
}
console.log("One run per failing step");
for (const failing of ["", ...saga.filter(([, , , why]) => why).map(([step]) => step)]) {
console.log("");
console.log(failing ? `'${failing}' fails` : "No step fails");
console.log(` result: order ${run(failing)}`);
} In TypeScript: Kind is a union of the three kinds, so a misspelt kind in the saga table is a compile error; the rest is the JavaScript program.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"slices"
)
type step struct {
name string
kind string // compensable, pivot or retryable
compensation string
why string // why a run fails at this step
}
var saga = []step{
{"create order", "compensable", "cancel order", ""},
{"reserve stock", "compensable", "release stock", "out of stock"},
{"authorise payment", "compensable", "void the authorisation", "card declined"},
{"capture payment", "pivot", "", "capture refused"},
{"book courier", "retryable", "", "courier unavailable"},
{"send confirmation", "retryable", "", ""},
}
func run(failing string) string {
var done []string
for _, s := range saga {
if s.name == failing && s.kind != "retryable" {
fmt.Printf(" failed %s: %s\n", s.name, s.why)
for _, undo := range slices.Backward(done) { // compensations run newest first
fmt.Printf(" undo %s\n", undo)
}
return "CANCELLED"
}
if s.name == failing { // after the pivot: retried until it works
for attempt := 1; attempt <= 2; attempt++ {
fmt.Printf(" retry %s: attempt %d failed (%s)\n", s.name, attempt, s.why)
}
}
note := ""
if s.kind == "pivot" {
note = " (pivot: no way back)"
}
fmt.Printf(" done %s%s\n", s.name, note)
if s.compensation != "" {
done = append(done, s.compensation)
}
}
return "CONFIRMED"
}
func main() {
cases := []string{""}
for _, s := range saga {
if s.why != "" {
cases = append(cases, s.name)
}
}
fmt.Println("One run per failing step")
for _, failing := range cases {
if failing == "" {
fmt.Println("\nNo step fails")
} else {
fmt.Printf("\n'%s' fails\n", failing)
}
fmt.Printf(" result: order %s\n", run(failing))
}
} In Go: slices.Backward walks done from its newest compensation to its oldest, and the runs to make are collected in one loop over the saga.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
enum Kind { case compensable, pivot, retryable }
// (step, kind, compensation, why a run fails at this step)
let saga: [(step: String, kind: Kind, compensation: String, why: String)] = [
("create order", .compensable, "cancel order", ""),
("reserve stock", .compensable, "release stock", "out of stock"),
("authorise payment", .compensable, "void the authorisation", "card declined"),
("capture payment", .pivot, "", "capture refused"),
("book courier", .retryable, "", "courier unavailable"),
("send confirmation", .retryable, "", ""),
]
func run(_ failing: String) -> String {
var done: [String] = []
for (step, kind, compensation, why) in saga {
if step == failing && kind != .retryable {
print(" failed \(step): \(why)")
// compensations run newest first
for undo in done.reversed() {
print(" undo \(undo)")
}
return "CANCELLED"
}
if step == failing {
// after the pivot: retried until it works
for attempt in 1...2 {
print(" retry \(step): attempt \(attempt) failed (\(why))")
}
}
print(" done \(step)" + (kind == .pivot ? " (pivot: no way back)" : ""))
if !compensation.isEmpty {
done.append(compensation)
}
}
return "CONFIRMED"
}
print("One run per failing step")
for failing in [""] + saga.filter { !$0.why.isEmpty }.map(\.step) {
print()
print(failing.isEmpty ? "No step fails" : "'\(failing)' fails")
print(" result: order \(run(failing))")
} In Swift: The tuple labels name each step's four fields, and done.reversed() gives the compensations newest first without changing done.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# why: why a run fails at this step
Step = Data.define(:name, :kind, :compensation, :why)
SAGA = [
Step['create order', :compensable, 'cancel order', ''],
Step['reserve stock', :compensable, 'release stock', 'out of stock'],
Step['authorise payment', :compensable, 'void the authorisation', 'card declined'],
Step['capture payment', :pivot, '', 'capture refused'],
Step['book courier', :retryable, '', 'courier unavailable'],
Step['send confirmation', :retryable, '', '']
].freeze
def compensate(step, done)
puts " failed #{step.name}: #{step.why}"
# compensations run newest first
done.reverse_each { |undo| puts " undo #{undo}" }
'CANCELLED'
end
def retry_twice(step)
# after the pivot: retried until it works
[1, 2].each do |attempt|
puts " retry #{step.name}: attempt #{attempt} failed (#{step.why})"
end
end
def run(failing)
done = []
SAGA.each do |step|
return compensate(step, done) if step.name == failing && step.kind != :retryable
retry_twice(step) if step.name == failing
puts " done #{step.name}#{' (pivot: no way back)' if step.kind == :pivot}"
done << step.compensation unless step.compensation.empty?
end
'CONFIRMED'
end
puts 'One run per failing step'
['', *SAGA.reject { |step| step.why.empty? }.map(&:name)].each do |failing|
puts
puts failing.empty? ? 'No step fails' : "'#{failing}' fails"
puts " result: order #{run(failing)}"
end In Ruby: Data.define makes Step an immutable value, and RuboCop's limit of 10 lines a method splits run into compensate and retry_twice.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
enum class Kind { COMPENSABLE, PIVOT, RETRYABLE }
// why: why a run fails at this step
data class Step(
val name: String,
val kind: Kind,
val compensation: String,
val why: String,
)
val saga =
listOf(
Step("create order", Kind.COMPENSABLE, "cancel order", ""),
Step("reserve stock", Kind.COMPENSABLE, "release stock", "out of stock"),
Step("authorise payment", Kind.COMPENSABLE, "void the authorisation", "card declined"),
Step("capture payment", Kind.PIVOT, "", "capture refused"),
Step("book courier", Kind.RETRYABLE, "", "courier unavailable"),
Step("send confirmation", Kind.RETRYABLE, "", ""),
)
fun run(failing: String): String {
val done = mutableListOf<String>()
for ((name, kind, compensation, why) in saga) {
if (name == failing && kind != Kind.RETRYABLE) {
println(" failed $name: $why")
// compensations run newest first
for (undo in done.asReversed()) {
println(" undo $undo")
}
return "CANCELLED"
}
if (name == failing) {
// after the pivot: retried until it works
for (attempt in 1..2) {
println(" retry $name: attempt $attempt failed ($why)")
}
}
println(" done $name" + if (kind == Kind.PIVOT) " (pivot: no way back)" else "")
if (compensation.isNotEmpty()) {
done.add(compensation)
}
}
return "CONFIRMED"
}
fun main() {
println("One run per failing step")
for (failing in listOf("") + saga.filter { it.why.isNotEmpty() }.map { it.name }) {
println()
println(if (failing.isEmpty()) "No step fails" else "'$failing' fails")
println(" result: order ${run(failing)}")
}
} In Kotlin: The data class destructures in the for loop as (name, kind, compensation, why), and asReversed() is a reversed view of done, not a copy.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
enum Kind { compensable, pivot, retryable }
// (step, kind, compensation, why a run fails at this step)
const saga = [
('create order', Kind.compensable, 'cancel order', ''),
('reserve stock', Kind.compensable, 'release stock', 'out of stock'),
(
'authorise payment',
Kind.compensable,
'void the authorisation',
'card declined',
),
('capture payment', Kind.pivot, '', 'capture refused'),
('book courier', Kind.retryable, '', 'courier unavailable'),
('send confirmation', Kind.retryable, '', ''),
];
String run(String failing) {
final done = <String>[];
for (final (step, kind, compensation, why) in saga) {
if (step == failing && kind != Kind.retryable) {
print(' failed $step: $why');
// compensations run newest first
for (final undo in done.reversed) {
print(' undo $undo');
}
return 'CANCELLED';
}
if (step == failing) {
// after the pivot: retried until it works
for (final attempt in [1, 2]) {
print(' retry $step: attempt $attempt failed ($why)');
}
}
print(
' done $step${kind == Kind.pivot ? ' (pivot: no way back)' : ''}',
);
if (compensation.isNotEmpty) {
done.add(compensation);
}
}
return 'CONFIRMED';
}
void main() {
print('One run per failing step');
final failing = saga.where((s) => s.$4.isNotEmpty).map((s) => s.$1);
for (final name in ['', ...failing]) {
print('');
print(name.isEmpty ? 'No step fails' : "'$name' fails");
print(' result: order ${run(name)}');
}
} In Dart: Each step is a record, destructured in the for-in loop, and $1 and $4 read a record's first and fourth fields.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
One run per failing step No step fails done create order done reserve stock done authorise payment done capture payment (pivot: no way back) done book courier done send confirmation result: order CONFIRMED 'reserve stock' fails done create order failed reserve stock: out of stock undo cancel order result: order CANCELLED 'authorise payment' fails done create order done reserve stock failed authorise payment: card declined undo release stock undo cancel order result: order CANCELLED 'capture payment' fails done create order done reserve stock done authorise payment failed capture payment: capture refused undo void the authorisation undo release stock undo cancel order result: order CANCELLED 'book courier' fails done create order done reserve stock done authorise payment done capture payment (pivot: no way back) retry book courier: attempt 1 failed (courier unavailable) retry book courier: attempt 2 failed (courier unavailable) done book courier done send confirmation result: order CONFIRMED
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 12 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 152 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 5 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 20 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 719 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 184 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 14 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Every failure up to the pivot ends with the order cancelled, after the compensations of the steps that ran, newest first; a refused capture voids the hold as well, and nothing is ever charged. The failure after the pivot ends with two retries and a confirmed order. No run ends half-done, which is the guarantee a saga gives in place of atomicity.
Key idea
Undo cheaply before the pivot and only move forward after it: the steps most likely to fail go first, the irreversible step after every check, and only retryable steps after that.
Checkpoint
Check: Where the pivot goes
Orchestration keeps the flow in one place; choreography spreads it
The same saga, orchestrated and choreographed
Text description of the diagram
The diagram has two panels, one above the other.
- Orchestration: an order orchestrator, which keeps the saga's state, exchanges commands and replies with three services listed below it, numbered in order: it sends "reserve" to the stock service and gets "reserved" back, sends "charge" to the payment service and gets "charged" back, then sends "book courier" to the shipping service.
- Choreography: there is no coordinator. The order service publishes OrderPlaced, which the stock service reacts to; the stock service publishes StockReserved, which the payment service reacts to; the payment service publishes PaymentCaptured, which the shipping service reacts to. A dashed arrow shows the failure path: on PaymentFailed, the stock service releases the reservation.
In orchestration, a coordinator keeps the saga’s state, sends each service a command and decides the next step from the reply. The whole flow lives in one place, easy to read, change and monitor, at the cost of one more component that must stay available.
In choreography, there is no coordinator. Each service listens for the events of the step before and publishes its own, so the services stay loosely coupled, but the flow exists only as the sum of their subscriptions, which makes it hard to see, test and change. Step through the same declined card in both styles:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Orchestration: reserve. The orchestrator in the order service saves the saga's state and commands the stock service to reserve one unit.
- The reply. Stock commits the reservation and replies. The orchestrator records the reply before it decides the next step.
- Authorise. The orchestrator commands the payment service to authorise the card, and the card is declined.
- The orchestrator compensates. On the declined reply the orchestrator itself sends the compensation. The whole flow, success and failure, is in its code, and one row of its state says where the saga is.
- Choreography: an event. Run it again without a coordinator. The order service commits the order and publishes OrderPlaced; it commands nobody.
- Stock reacts. Stock subscribes to OrderPlaced, reserves a unit and publishes StockReserved, which the payment service subscribes to.
- Payments reacts. The card is declined, so payments publishes PaymentFailed. It does not know who will undo what; it only announces what happened.
- Each one undoes its own step. Stock releases the unit when it sees PaymentFailed, and the order service cancels the order on the event that follows. The flow exists only as the sum of the services' subscriptions.
Azure weighs the two the same way: choreography for simple flows with few services, orchestration for complex flows that change. Answer four questions about your saga, and the table marks the style that fits and why:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Style | Seeing where a saga is | Adding a step | Coupling | Moving parts |
|---|---|---|---|---|
| Orchestration Who picks the next step: one coordinator, from the saga state it saves | Good one row of the orchestrator's state | Good change the orchestrator, add the service | Mixed services know commands, not each other | Mixed one more component, which must stay available |
| Choreography Who picks the next step: every service, from the events it subscribes to | Poor join the logs of every service | Poor change what two or more services publish and consume | Good services know only each other's events | Mixed none extra, but event chains can form cycles |
How the answers decide
- “How many steps does the saga have?” Four or more: adds a caveat to Choreography. With four or more steps the flow exists only as the sum of many subscriptions, which is hard to see, test and change.
- “How often does its flow change, and how many teams change it?” Often, or several teams change it: rules out Choreography. Each change touches what several services publish and consume, and their releases have to be ordered so no event is lost in between.
- “Does it move money, or must someone be able to say where each saga stands?” Yes: adds a caveat to Choreography. Finding a stuck payment means joining every service's logs; an orchestrator answers with one row of its state.
- “Can one team own and run a coordinator for it?” No, nobody would own it: adds a caveat to Orchestration. An orchestrator is one more component that must stay available; without an owner it becomes the part nobody fixes.
- “How many steps does the saga have?” Two or three and “How often does its flow change, and how many teams change it?” Rarely, and one team owns it: adds a caveat to Orchestration. For a short, stable chain an orchestrator adds a moving part that buys little; Azure suggests choreography for such simple flows.
Checkpoint
Check: Orchestration or choreography
Practise: plan the recovery
Write the two rules the stepper follows: what a failure at a given step must undo, and which compensable steps sit where their compensation could never run:
Exercise · Medium · Python
Plan a saga's recovery and check where its pivot sits
A saga is a list of steps. Each step is a dictionary with a "name", a "kind" ("compensable", "pivot" or "retryable") and an "undo": the name of its compensation, or None for the pivot and retryable steps. Write two functions in recovery.py.
recovery_plan(saga, failed) says what to do when the step at index failed fails. That step's own local transaction has already rolled back, so it needs no compensation.
- If the failed step is compensable or the pivot, return
{"action": "compensate", "undo": [...]}: the compensations of every step before it, newest first. - If the failed step is retryable, the pivot has already succeeded and there is no way back, so return
{"action": "retry", "undo": []}.
misplaced(saga) returns the names of compensable steps that come after the pivot, in saga order. Such a step can never be compensated, because a failure after the pivot is retried, not undone. A saga without a pivot has no misplaced steps.
For a saga of create order, reserve stock (both compensable), capture payment (the pivot) and book courier (retryable), a failure of capture payment returns {"action": "compensate", "undo": ["release stock", "cancel order"]}.
Starter code · recovery.py
def recovery_plan(saga, failed):
"""What to do when step number `failed` of the saga fails."""
# Replace this line with your code.
return {"action": "retry", "undo": []}
def misplaced(saga):
"""Names of compensable steps placed after the pivot."""
# Replace this line with your code.
return [] The sample tests · test_recovery.py
from recovery import misplaced, recovery_plan
ORDER = [
{"name": "create order", "kind": "compensable", "undo": "cancel order"},
{"name": "reserve stock", "kind": "compensable", "undo": "release stock"},
{"name": "authorise payment", "kind": "compensable", "undo": "void authorisation"},
{"name": "capture payment", "kind": "pivot", "undo": None},
{"name": "book courier", "kind": "retryable", "undo": None},
]
def test_first_step_fails():
"""a failure of the first step needs no compensation"""
assert recovery_plan(ORDER, 0) == {"action": "compensate", "undo": []}
def test_pivot_fails():
"""a failed pivot undoes every earlier step, newest first"""
assert recovery_plan(ORDER, 3) == {"action": "compensate", "undo": ["void authorisation", "release stock", "cancel order"]}
def test_middle_step_fails():
"""a failed compensable step undoes only the steps before it"""
assert recovery_plan(ORDER, 2) == {"action": "compensate", "undo": ["release stock", "cancel order"]}
def test_after_the_pivot():
"""a step after the pivot is retried, never compensated"""
assert recovery_plan(ORDER, 4) == {"action": "retry", "undo": []}
def test_misplaced_steps():
"""compensable steps after the pivot are reported in saga order"""
bad = ORDER[:4] + [
{"name": "add loyalty points", "kind": "compensable", "undo": "remove loyalty points"},
{"name": "book courier", "kind": "retryable", "undo": None},
{"name": "send voucher", "kind": "compensable", "undo": "revoke voucher"},
]
assert misplaced(bad) == ["add loyalty points", "send voucher"]
assert misplaced(ORDER) == []
assert misplaced(ORDER[:3]) == [] A hint
For recovery_plan, look at saga[failed]["kind"] first: a retryable step decides the answer on its own. Otherwise collect step["undo"] for every step in saga[:failed] and reverse the list. For misplaced, find the index of the pivot, if there is one, and keep the compensable steps after it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise the three ideas above. The session leads with whichever one your answers have shown to be the weakest.
Practice that adapts to you
Practise this lesson
9 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Failure drills: crashes, unknown outcomes and sagas that collide Pro: opens with a Pro, Premium or Ultimate pass
A saga’s happy path is easy to write; its failures sit between the steps. This part crashes an orchestrator in the middle of a saga, makes a compensation fail for good, leaves a payment’s outcome unknown, and runs 150 buyers into the gap that a saga leaves open.
In this part:
- An orchestrator that crashes and resumes, reproduced
- A payment step whose outcome is unknown
- Sagas that see each other’s half-finished work, measured
2 diagrams · 2 runnable examples (JavaScript, Python) · about 12 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing a design with numbers, and how answers are judged Pro: opens with a Pro, Premium or Ultimate pass
The pivot’s position and the coordination style are both decisions with costs you can count. This part puts numbers on them for 100,000 orders and ends with what a good interview answer covers at each level.
In this part:
- Where the pivot goes, in refunds per 100,000 orders
- Messages, latency and change, counted for both styles
- Finding stuck sagas before customers do
- What a good answer covers at each level
1 runnable example (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why is a compensating transaction not the same as a rollback?
A rollback undoes a transaction that never committed: the database discards its changes, and nobody else ever saw them. A saga's steps have already committed in their own services, other requests may have read or built on their results, and some effects cannot be withdrawn at all: a sent email, a shipped parcel, money that has left an account. A compensating transaction is therefore a new action with the opposite business effect, such as releasing a reservation, voiding a card hold or issuing a refund. It does not have to restore the data exactly; it has to leave the business in an acceptable state, and it must be safe to run twice, because it can be retried after a crash.
-
Mid to senior
A trip booking reserves a flight seat, a hotel room and an airport cab from three providers, then charges the card. Design the saga: the steps, their compensations, where the pivot goes, and what the customer sees while it runs.
-
Senior to staff
Your order flow is choreographed across six services. Some orders stay "pending" for days and nobody can say why. Would you move to orchestration? Say what you would change, what it costs and how you would migrate.
-
Mid to senior
In an order saga, the call that captures the payment times out. The money may or may not have moved. What should the saga do next, and what must it never do?
Key takeaways
- A saga is a sequence of local transactions, each with a compensation that undoes it in business terms: either every step runs, or the completed steps are compensated in reverse order.
- A compensation is a new action, not a rollback, and other requests may already have seen the step it undoes.
- Put the steps most likely to fail before the pivot, the irreversible step after every check, and only retryable steps after it.
- Orchestration keeps the flow in one place; choreography keeps services decoupled, at the cost of a flow that nobody sees whole.
Terms in this lesson
- choreography (saga)
- Running a saga without a coordinator: each service reacts to the events of the step before and publishes its own, so the flow is the sum of the services' subscriptions.
- compensating transaction
- A new transaction with the opposite business effect of a step that has already committed, such as releasing a reservation or issuing a refund. It does not restore old values; it leaves the business in an acceptable state.
- orchestration (saga)
- Running a saga from one coordinator that keeps the saga's state, sends each service a command and decides the next step from the reply.
- pivot (saga)
- The step of a saga after which it can no longer turn back, such as capturing a payment. The steps before it can be compensated; the steps after it are retried until they succeed.
- saga
- A business transaction across several services, run as a sequence of local transactions, each paired with a compensating transaction that undoes its effect if a later step fails.
References
- Sagas (Princeton technical report CS-TR-070-87) (Princeton University (Garcia-Molina and Salem))
- Saga design pattern (Azure Architecture Center) (Microsoft)
- Compensating Transaction pattern (Azure Architecture Center) (Microsoft)
- Idempotent Consumer pattern (Azure Architecture Center) (Microsoft)
- Harmonisation of turn around time and customer compensation for failed transactions (Reserve Bank of India)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress