Sagas: orchestration vs choreography

Distributed systems Senior Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

Stock reservation fails. What does the saga log?

Python 3.13
from collections.abc import Callable

Step = tuple[str, Callable[[], None], Callable[[], None]]
log: list[str] = []


def act(name: str, fail: bool = False) -> Callable[[], None]:
    def run() -> None:
        if fail:
            raise RuntimeError(f"{name} failed")
        log.append(name)
    return run


def run_saga(steps: list[Step]) -> None:
    done: list[Step] = []
    for step in steps:
        try:
            step[1]()
            done.append(step)
        except RuntimeError as e:
            log.append(str(e))
            for _, _, compensate in reversed(done):
                compensate()
            return


run_saga([
    ("order", act("create order"), act("cancel order")),
    ("payment", act("charge card"), act("refund card")),
    ("stock", act("reserve stock", fail=True), act("release stock")),
    ("ship", act("book courier"), act("cancel courier")),
])
print(log)

Options
Hint 1 of 1

Which steps completed?

Choose an answer, then open Show answer below to check it.

Order saga: create order, charge card, reserve stock, book courier. Stock fails, so the card is refunded and the order cancelled in reverse order; the courier step never runs. Shown once with an orchestrator and once with services reacting to events.

Illustrative; not a load test

Text version

Orchestration:

  1. Orchestrator → Orders: create: ✓ done
  2. Orchestrator → Payment: charge: ✓ done
  3. Orchestrator → Stock: reserve: ✕ failed
  4. Courier is never asked: – skipped
  5. Orchestrator → Payment: refund: ↩ compensated
  6. Orchestrator → Orders: cancel: ↩ compensated

Choreography:

  1. Orders saves order, emits OrderCreated: ✓ done
  2. Payment svc ← OrderCreated: charges: ✓ done
  3. Stock svc ← PaymentTaken: out of stock: ✕ failed
  4. Courier svc never hears StockReserved: – skipped
  5. Payment svc ← StockFailed: refunds: ↩ compensated
  6. Orders ← PaymentRefunded: cancels: ↩ compensated
Show answer

Answer: A ['create order', 'charge card', 'reserve stock failed', 'refund card', 'cancel order']

Correct. Completed steps are compensated in reverse; the courier step never ran.

A saga undoes the steps that already succeeded, newest first. Payment is refunded before the order is cancelled, and the courier step never ran, so it has nothing to undo.

Why not the others?
  • B ['create order', 'charge card', 'reserve stock failed']. The compensations also append to the log.
  • C ['create order', 'charge card', 'reserve stock failed', 'cancel order', 'refund card']. Compensation runs in reverse order: refund first.
  • D ['create order', 'charge card', 'reserve stock failed', 'refund card', 'cancel order', 'cancel courier']. The courier was never booked, so there's nothing to cancel.

30-second answer

Use a saga: a sequence of local transactions, each with a compensating action that semantically undoes it (refund, release stock). If a step fails, the saga runs the compensations for the completed steps in reverse. Orchestration puts the sequence in one coordinator that tells each service what to do; choreography has each service react to the previous one's events. Orchestration is easier to follow and change; choreography has less central coupling.

Practise saying it · 40 s. No recording, just a timer

Explain sagas.

Say your answer out loud, then check which of these points you covered:

  • Local transactions with compensations
  • Reverse on failure
  • Orchestration vs choreography
  • Idempotent steps
  • Riskiest step last

Deep dive

Compensations aren't rollbacks: a refund is a new action, and others may have seen the intermediate state.

Every step and compensation must be idempotent, because messages are retried.

Order steps so the hardest-to-undo one (shipping, emailing) goes last.

Persist saga state so a crashed orchestrator resumes; workflow engines (Temporal, AWS Step Functions) do this.

A tiny orchestrator with compensation Ran on Python 3.13.5 · mypy --strict
Python 3.13
from collections.abc import Callable

Step = tuple[str, Callable[[], None], Callable[[], None]]
log: list[str] = []


def act(name: str, fail: bool = False) -> Callable[[], None]:
    def run() -> None:
        if fail:
            raise RuntimeError(f"{name} failed")
        log.append(name)
    return run


def run_saga(steps: list[Step]) -> None:
    done: list[Step] = []
    for step in steps:
        try:
            step[1]()
            done.append(step)
        except RuntimeError as e:
            log.append(str(e))
            for _, _, compensate in reversed(done):
                compensate()
            return


run_saga([
    ("order", act("create order"), act("cancel order")),
    ("payment", act("charge card"), act("refund card")),
    ("stock", act("reserve stock", fail=True), act("release stock")),
    ("ship", act("book courier"), act("cancel courier")),
])
print(log)

Common mistakes

  • Expecting isolation: other requests see half-finished sagas.
  • Compensations that can themselves fail with no retry plan.
  • Choreography across many services with no one able to see the whole flow.

Senior take & follow-ups

Define steps and compensations, compare the two styles, and stress idempotency and step order.

Follow-up questions

Think of your answer first, then open the hint.

  1. What if a compensation fails?

    Show hint

    Retry it; if it keeps failing, alert a human with the saga state.

  2. How do you handle isolation?

    Show hint

    Semantic locks (a 'pending' status), commutative updates, or re-reading values.

Suggest a fix if something here is wrong or out of date.