The full picture
| Orchestration | Choreography | |
|---|---|---|
| Flow lives in | one coordinator, explicitly | emergent from event subscriptions |
| "Where is order 123 stuck?" | query the state machine | correlate logs across N services |
| Coupling | coordinator knows every participant | services know only events |
| Retries, timeouts, compensation order | centralized, testable | duplicated in every handler |
| Good for | flows with real branching, money, SLAs | 2–3 steps, simple fan-out reactions |
// Each step is its OWN committed local transaction. There is no global undo,
// so every step ships with the business action that semantically reverses it.
saga.step("createOrder", this::createPending, this::cancelOrder)
.step("chargeCard", this::charge, this::refund)
.step("reserveStock", this::reserve, this::releaseReservation)
.step("confirmOrder", this::confirm, null); // last step needs none
// A refund is NOT a rollback: the charge really happened, the customer really
// saw it on their statement, and the refund is a second visible event. That is
// the honest description to give an interviewer.
// Ordering rule that matters: put the steps you CANNOT compensate last.
// "Send the shipping notification" belongs after everything reversible,
// because there is no un-send.- Order steps by reversibility, and say why: fully reversible first (a database row), then costly-but-possible (a refund carries fees and support load), then irreversible last (email sent, package shipped, third-party API with no cancel). Many production sagas are simply the same steps in a smarter order — that's a design insight, not a framework feature.
- Every step and every compensation must be idempotent and retryable, because the coordinator will re-drive them after a crash. A compensation that fails is not a corner case — it's the normal case at scale — so compensations need retries, a dead-letter path and an alert, not a
catchblock that logs and moves on. - Pivot transactions are the concept worth naming: the step after which the saga can no longer be abandoned, only driven forward. Before the pivot you compensate backwards; after it you retry forwards until it succeeds. Knowing where your pivot is tells you which failures need a refund and which need an on-call engineer.
- Choreography's real cost shows up on day 200, not day 1: with events triggering events, nobody can answer 'what happens when a payment succeeds?' without reading every service. Orchestration trades a bit of coupling for a flow you can draw, test and query — which is why Temporal and Step Functions won this argument in practice.
- The saga is not a smaller 2PC — it is a different guarantee. 2PC gives you atomicity with no visible intermediate state and blocking on failure. A saga gives you eventual atomicity with very visible intermediate state and no blocking. Say which property your domain actually needs rather than presenting the saga as a strict upgrade.