How you get “transactions” across services when real transactions aren’t available: a sequence of local commits, each with an undo. You trade isolation for availability, and the undo logic is business logic.
Prerequisites: Distributed Transactions, Message Queues Time to read: ~24 minutes
Placing an order touches four services:
1. Order Service → create the order
2. Payment Service → charge the card
3. Inventory Service → reserve the items
4. Shipping Service → schedule delivery
Each has its own database. There is no transaction spanning them, and 2PC blocks if the coordinator dies.
🚨 What happens if step 3 fails after step 2 succeeded? The customer has been charged and will receive nothing. You need a way to undo.
A saga is a sequence of local transactions. Each one commits immediately in its own service. If a later step fails, you run compensating transactions to undo the earlier ones, in reverse.
Forward: T1 → T2 → T3 → T4
Compensating: C1 ← C2 ← C3 (if T4 fails)
| Step | Forward | Compensation |
|---|---|---|
| 1 | Create order (pending) | Mark order cancelled |
| 2 | Charge card | Refund |
| 3 | Reserve inventory | Release reservation |
| 4 | Schedule shipment | Cancel shipment |
🚨 Compensation is not rollback. A database rollback erases the transaction as though it never happened. A compensation is a new business fact: the charge happened and then a refund happened. Both appear on the customer’s statement. Both are in your ledger. The intermediate state was real and was visible to everyone.
That distinction is the heart of the pattern, and it’s where the design work is.
No central coordinator. Each service listens for events and emits its own.
flowchart LR
O[Order Service] -->|OrderCreated| P[Payment Service]
P -->|PaymentCompleted| I[Inventory Service]
I -->|InventoryReserved| S[Shipping Service]
I -->|InventoryFailed| P2[Payment: refund]
✅ Loosely coupled — no service knows about the whole flow. Adding a step means adding a subscriber. ✅ No single point of failure or bottleneck.
❌ 🚨 The flow exists nowhere. To understand what happens when an order is placed, you must read four codebases and infer the sequence from event subscriptions. There’s no diagram that is authoritative. ❌ Debugging is hard — “why didn’t step 3 run?” requires tracing across services. ❌ Cyclic dependencies creep in as the flow grows.
Use for: short sagas (2–4 steps) with simple flows.
A saga orchestrator explicitly tells each service what to do and tracks the state.
flowchart TB
SO[Order Saga Orchestrator]
SO -->|1. reserve| I[Inventory]
SO -->|2. charge| P[Payment]
SO -->|3. ship| S[Shipping]
I -.->|ok / fail| SO
P -.->|ok / fail| SO
S -.->|ok / fail| SO
✅ The flow is explicit and in one place. You can read it, draw it, and test it. ✅ State is tracked — you always know which step an order is on. ✅ Easier to debug, monitor, and modify. ✅ Complex conditional logic is manageable.
❌ More coupling — the orchestrator knows about every service. ❌ Another component to build and run (though workflow engines provide it). ❌ Risk of becoming a god object with all the business logic.
Use for: longer sagas, anything with conditional branching, anything where you need to answer “where is order 4821 right now?”
🎙️ The recommendation to state: “I’d use orchestration here. With five steps and conditional compensation, choreography means the flow exists only implicitly across five codebases — nobody can tell you what happens without reading all of them. An orchestrator makes the process explicit and gives us a place to track state.”
Choreography is often the default because it feels more “microservices,” and it’s frequently the wrong choice for anything non-trivial. Saying so is a good signal.
🚨 This is where sagas actually get hard, and it’s what interviewers probe.
| Action | Compensation | Reality |
|---|---|---|
| Reserve inventory | Release it | ✅ Clean |
| Charge a card | Refund | ⚠️ Money moved twice; fees may not be refunded; it’s visible to the customer |
| Send an email | — | ❌ Impossible. You cannot unsend it |
| Ship a package | Recall it | ❌ Not really — it’s on a van |
| Publish to a partner API | Call their delete endpoint | ⚠️ If they support it, and if it succeeds |
The design response: order your steps so irreversible actions come last.
❌ send_email → charge_card → reserve_inventory
(if inventory fails, you've emailed a confirmation for an order that won't happen)
✅ reserve_inventory → charge_card → send_email
(the irreversible step only runs once everything reversible has succeeded)
🚨 “Put the irreversible steps last” is one of the most useful concrete rules in this chapter.
Pivot transactions are the formal version: the step after which the saga can no longer be compensated and must run to completion. Everything before it is compensatable; everything after must be retried until it succeeds. Identify yours explicitly.
The refund API is down. Now what?
🚨 Compensations must be retried until they succeed, which means they must be:
There is no “give up” option — a failed compensation means a customer has been charged for nothing. It escalates to a person.
Since there’s no isolation, mark records to signal an in-flight saga:
UPDATE orders SET status = 'payment_pending' WHERE id = 42;
Other processes see the state and behave accordingly — don’t ship it, don’t let it be edited, show “processing” in the UI. It’s a convention, not a lock, and every reader must honour it.
🚨 Sagas provide A, C, and D — but not I. Intermediate states are visible to everything else, and that causes real anomalies:
Dirty reads. Another transaction sees the inventory as reserved during a saga that later compensates. It made a decision on state that was rolled back.
Lost updates. Someone modifies the order while the saga is mid-flight; the compensation overwrites their change.
The classic: inventory is reserved, another customer is told “out of stock,” then the saga compensates and the item is available again — but that customer has gone.
Countermeasures (from Garcia-Molina’s original saga paper, and still the standard vocabulary):
| Technique | How |
|---|---|
| Semantic lock | A status flag marking the record as in-flight |
| Commutative updates | Design operations so order doesn’t matter (increment, not set) |
| Pessimistic view | Reorder steps so the risky ones happen when least data is exposed |
| Re-read value | Verify data hasn’t changed before acting on it |
| By-value | Route high-value transactions through a real distributed transaction, low-value ones through a saga |
⚖️ That last one is a genuinely practical idea: use a saga for a $20 order and something stricter for a $2 million one, because the cost of an anomaly scales with value.
State must be durable. The saga’s current step must survive a crash — persist it in a database or a workflow engine, never in memory.
CREATE TABLE saga_instances (
id UUID PRIMARY KEY,
saga_type TEXT NOT NULL,
current_step INT NOT NULL,
state JSONB NOT NULL,
status TEXT NOT NULL, -- running | compensating | completed | failed
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Every step must be idempotent. Steps will be retried — timeouts are ambiguous (the fallacies) and the orchestrator may crash after a step succeeded but before recording it.
Use the transactional outbox to publish saga events atomically with the local state change. → CDC
Timeouts on every step. A saga waiting forever on an unresponsive service is stuck. Define what happens on timeout — usually compensate, but sometimes retry.
Workflow engines do all of this for you. Temporal, AWS Step Functions, Camunda, and Cadence provide durable execution, retries, compensation, timeouts, and visibility.
🚨 Temporal’s model deserves a mention because it’s the modern answer: you write ordinary sequential code, and the engine records every step’s result. If the process dies, execution resumes by replaying the recorded history. Waiting three days for a webhook is one line.
@workflow.defn
class OrderSaga:
@workflow.run
async def run(self, order):
try:
await workflow.execute_activity(reserve_inventory, order)
await workflow.execute_activity(charge_payment, order)
await workflow.execute_activity(schedule_shipping, order)
except ActivityError:
await workflow.execute_activity(compensate, order)
raise
🎙️ “I’d use a workflow engine rather than hand-rolling the orchestrator. Durable state, retries, timeouts, and visibility are all things we’d otherwise build badly.”
⚖️ Sagas are not free. Before reaching for one:
| Gain | Cost | |
|---|---|---|
| Saga vs 2PC | No locks, no blocking, scales, high availability | No isolation; compensation logic to write and test |
| Orchestration | Explicit flow, visible state, easy debugging | Coupling; another component |
| Choreography | Loose coupling, no coordinator | The flow exists nowhere; hard to debug |
| Workflow engine | Durability, retries, visibility for free | A platform to learn and operate |
| Semantic locks | Reduces anomalies from missing isolation | Every reader must honour the convention |
| Sagas at all | Cross-service business transactions | Intermediate states are user-visible |
1. Build a saga by hand. Three services (order, payment, inventory) with their own databases and an orchestrator. Make step 3 fail deliberately. Watch the compensations run. Then:
2. Feel the isolation problem. While a saga has inventory reserved mid-flight, have another process read the inventory count and make a decision. Then let the saga compensate. The second process acted on state that was rolled back — and there was no way for it to know.
3. Try Temporal. Implement the same order saga as a Temporal workflow. Kill the worker process mid-execution and watch it resume exactly where it left off. The durability is genuinely impressive, and it makes clear how much you’d otherwise be building.
4. Compare coordination styles. Implement the same flow as choreography (services reacting to events) and orchestration. Then ask a colleague to explain the flow from the code. The difference in how long that takes is the argument.