Sagas & Distributed Transactions

A saga is a business transaction spread across multiple services, implemented as a sequence of local transactions where each step publishes an event or command that triggers the next. When a step fails, the saga runs compensating actions to undo the work already committed — because there is no distributed rollback to fall back on.

The pattern exists because ACID doesn't cross process boundaries. Once orders, payments, and inventory live in three services with three databases, BEGIN … COMMIT can't span them. Two-phase commit technically can, but it holds locks across the network and makes availability the product of every participant's availability, which is why almost nobody uses it in a service architecture.

The tradeoff is explicit: you give up atomicity and isolation, keeping only consistency (eventually) and durability. Intermediate states become visible to users, and your business has to have an answer for each one.

TL;DR

Quick Example

An orchestrated order saga with explicit compensations:

Core Concepts

Why not just use a distributed transaction?

2PC isn't wrong so much as unaffordable: it converts five 99.9% services into one 99.5% transaction and can block every participant when the coordinator dies mid-prepare. Sagas trade isolation — which you can partially reconstruct — for availability, which you can't.

Compensation is forward motion, not rollback

A refund is not the inverse of a charge. The customer sees two ledger entries, possibly a fee, and a statement that looks odd. That's the honest reality of distributed business transactions, and it's why compensation is a business-logic question first and a technical one second. Ask the domain expert what should happen when step 3 fails after step 2 committed — often the answer isn't "undo," it's "escalate to support" or "ship anyway and reconcile."

Three kinds of step

Ordering matters enormously. Put the operations you can undo first, the irreversible commit in the middle, and the guaranteed-to-eventually-succeed operations last. Once "send the confirmation email" has run, you cannot un-send it — so it belongs after the pivot, never before.

Sagas are ACD, not ACID

The missing letter is I. Between step 1 and step 4 the system is in a state no single-database transaction would ever expose: money is charged but no shipment exists. Three countermeasures:

Semantic lock — mark the record as in-flight so other operations know to wait or refuse.

Commutative updates — make operations order-independent so interleaving doesn't corrupt state. balance = balance - 100 composes; balance = 400 does not.

Reread value / version check — before committing a later step, verify the data it depends on hasn't changed underneath.

Without these you get the classic anomalies: lost updates (a concurrent saga overwrites your step), and dirty reads (someone reads the charged-but-unshipped state and acts on it).

Choreography vs. Orchestration

Choreography — services react to events

Each service subscribes to the events it cares about and publishes its own. No component owns the flow.

Good: no single point of failure, services stay decoupled, adding a subscriber requires no changes elsewhere. Natural fit for event-driven architecture.

Bad: the business process exists in no single place. Understanding "what happens when an order is placed" means reading five codebases and inferring the graph. Cyclic dependencies creep in. Compensation logic is scattered. Debugging requires distributed tracing as a hard prerequisite.

Orchestration — a coordinator drives

Good: the process is explicit and testable in one place, compensation is centralized, and state is inspectable ("saga 91 is stuck at charge-payment"). Adding a step is a local change.

Bad: the orchestrator can accumulate business logic that belongs in the services, and it's another component to build, deploy, and keep available.

Which to choose

Real systems mix them: orchestrate the core money-touching flow, and let peripheral concerns (emails, analytics, search indexing) subscribe choreographically to the events it emits.

Implementation Concerns

Durable saga state is mandatory

An orchestrator holding progress in memory loses every in-flight saga on restart, leaving charges without shipments and reservations that never expire. Persist each transition:

On startup, resume every saga in running or compensating.

Every step needs an idempotency key

Retries are guaranteed — timeouts, redeliveries, orchestrator restarts. Derive keys deterministically from the saga ID and step name (${sagaId}:charge) so a retry after an unknown-outcome timeout reuses the same key and the downstream service dedupes it. This is the single most important implementation detail; see Idempotency.

Compensations must eventually succeed

A failed compensation leaves money charged and nothing shipped. Retry with backoff indefinitely, and when retries exhaust a threshold, page a human — don't swallow it.

Design compensations to be simpler than the forward step and to depend on fewer systems — a compensation that itself needs three services is a liability.

Timeouts at every step

A step that never returns and never fails is worse than one that fails. Every step gets a deadline; on expiry, treat the outcome as unknown — query the downstream service for the idempotency key's status before deciding to compensate.

Use a workflow engine before writing your own

Durable state, retries, timeouts, versioning, and visibility are exactly what workflow engines provide. Temporal is the common choice; AWS Step Functions, Azure Durable Functions, Camunda, and Restate solve the same problem. A hand-rolled orchestrator converges on a worse version of one of these.

Best Practices

Try hard not to need a saga

The cheapest distributed transaction is the one you don't have. If two steps must be atomic and the data could live together, that's evidence the service boundary is drawn in the wrong place. Merging two chatty services is often the correct fix — see domain-driven design on aggregate boundaries.

Place the pivot as late as possible

Everything before the pivot is undoable; everything after is committed. Maximize the reversible prefix. Reserve inventory (reversible) before charging (reversible with cost) before confirming (irreversible) before emailing (irreversible).

Model intermediate states in the domain, not as nulls

payment_pending, awaiting_inventory, compensating are legitimate business states with their own UI and their own rules. Representing them as "the row exists but charge_id is null" pushes the state machine into implicit conventions nobody can query.

Correlate everything

One saga ID threaded through every request, event, log line, and span. Without it, reconstructing a failed saga across five services is archaeology. See distributed tracing.

Monitor sagas as a business metric

Track: sagas started/completed/compensated per hour, time in each step, count stuck longer than the SLA, and compensation failure count. A rising compensation rate is a business signal (a downstream partner is degraded), not just a technical one.

Test the failure paths, because they're the point

The happy path is the easy part. Write tests that fail step 3 after step 2 committed, that fail a compensation, that deliver the same message twice, and that restart the orchestrator mid-saga. Chaos-test in staging — see chaos engineering.

Common Mistakes

No compensation for a step that needs one

Non-idempotent steps

Compensating in the wrong order

Irreversible actions before the pivot

In-memory saga state

Treating a timeout as a definite failure

FAQ

When should I use a saga instead of a database transaction?

Only when the data genuinely lives in separate databases owned by separate services. If one transaction can cover it, use one transaction — it's simpler, atomic, isolated, and faster. Needing a saga inside a single service is a sign the schema, not the architecture, needs work.

Choreography or orchestration?

Choreography for two or three steps with no branching; orchestration for anything with conditional paths, timeouts, or complex compensation. The practical tiebreaker: if someone will eventually ask "why is order 12345 stuck?", you want orchestration, because it can answer.

What if a compensation fails?

Retry with backoff, indefinitely, and alert a human once it's clearly not transient. This is why compensations should be designed to be simpler and depend on fewer systems than the forward action. Some organizations add a manual-intervention queue for permanently stuck sagas — that's a legitimate design, not a failure.

Can I get isolation back?

Partially. Semantic locks (mark records in-flight), commutative updates, pessimistic ordering of steps, and version checks recover most of what matters in practice. You cannot get true serializability across services — the pattern's premise is trading it away.

Is two-phase commit ever the right answer?

Occasionally: within one datacenter, few participants, an XA-capable stack, low throughput, and a hard atomicity requirement — some financial and legacy-integration workloads qualify. For internet-facing microservices, the availability math almost never works out.

Should I build my own orchestrator?

Almost certainly not. Durable state, retries, timeouts, versioning, and observability are a year of work you can adopt in a week from Temporal, Step Functions, or Durable Functions. See durable execution.

Related Topics

References