Saga Pattern (Distributed Transaction Choreography)
Practical example
An e-commerce order saga: charge payment, reserve inventory, schedule shipping. If shipping fails, the saga triggers compensations — release inventory reservation, then refund the payment — each as an idempotent, independently retryable operation rather than a true undo.
The Saga pattern exists because Two-Phase Commit (2PC) does not scale across autonomous service boundaries with independent datastores. 2PC requires a coordinator to hold locks on all participants until every node votes to commit, which creates a blocking dependency graph that collapses under partition or coordinator failure. Sagas trade atomicity for availability: each step commits locally and immediately, and failure recovery is handled retroactively via compensating transactions rather than proactively via locking. This is a direct application of the BASE model (Basically Available, Soft state, Eventually consistent) over ACID.
There are two execution topologies, and the choice has significant architectural blast radius:
- Orchestration-based Sagas: a central orchestrator (state machine) explicitly invokes each participant and issues compensations on failure. This centralizes the transaction graph, making it observable and testable, but the orchestrator becomes a critical-path dependency and a potential single point of coupling.
- Choreography-based Sagas: services react to events emitted by prior steps (typically via a broker) with no central controller. This maximizes decoupling but makes the end-to-end transaction graph implicit — debugging a stuck saga requires reconstructing intent from a distributed event trace, and cyclic event dependencies are a real failure mode.
The critical engineering hazard is that compensations are not rollbacks: they are new, forward-moving transactions that must be designed to be semantically valid regardless of intermediate state. If step 3 fails after step 1 (charge card) and step 2 (reserve inventory) succeeded, the compensation for step 1 is refund(), not undo_charge() — the money may have already left the account. This introduces the requirement that every participant expose an idempotent compensating operation, because the orchestrator/choreography layer will retry on ambiguous failures (timeout vs. actual failure), and duplicate compensations must be safe. Idempotency is typically enforced via a deduplication key stored alongside an outbox or saga-log entry.
A subtler failure class is the semantic lock window: between a step committing and its potential compensation, the system is in an intermediate, externally visible state (e.g., inventory reserved, payment pending). Concurrent readers can observe this uncommitted-in-aggregate state, which is why Sagas are frequently paired with the Outbox Pattern for reliable event emission and with explicit “pending/reserved” states in the domain model rather than pretending the operation is atomic. Sagas also fundamentally cannot guarantee isolation — two concurrent sagas can interleave and produce results neither would produce individually (a lost-update-style anomaly), so systems with high concurrency on the same aggregate often need additional application-level countermeasures such as semantic locks, versioning, or reordering compensations to be commutative.