What Is the Saga Pattern - and When a Single Transaction Is Still the Right Answer

The saga pattern is a way to run a business transaction that spans several services when you cannot hold a single database transaction across all of them. Instead of one atomic commit, a saga is a sequence of local transactions, each of which commits immediately, plus a compensating action for each step that undoes its effect if a later step fails.

The problem it exists to solve is narrow and specific. Once your order data lives in one database, your payment data in another, and your inventory in a third, BEGIN and COMMIT no longer cover the whole operation. You either run a distributed two-phase commit — which holds locks across services and turns every participant into a liveness dependency of every other — or you give up atomicity and manage the intermediate states yourself.

A saga is the second choice, made deliberately. You accept that the system will be visibly inconsistent for a while, and in exchange you never hold a lock across a network call. Everything hard about sagas follows from that trade. A view of the control tower at Londrina Airport in sunny weather.

ConceptSaga pattern (saga)
Where it sitsDistributed systems - sits at the application layer, above your services' local transactions and below the business workflow
In one sentenceA saga replaces one distributed transaction with a chain of local transactions, each with a compensating action that semantically undoes it if a later step fails.

The 60-Second Answer

A saga breaks a multi-service operation into local transactions that each commit on their own, with a compensating transaction defined for every step. If step 4 fails, you run the compensations for steps 3, 2, and 1 in reverse.

The one problem it solves: cross-service atomicity without distributed locks. No participant blocks waiting on another to commit.

The one cost it adds: you lose isolation. Intermediate states are visible to other readers, so anomalies that a database prevented for free — dirty reads, lost updates — are now your application's job to prevent or tolerate.

If all the data involved lives in one database, you don't need this. Use a transaction.

How It Actually Works

1. The operation is cut into local transactions that each commit immediately

No step waits for another

You split the business operation at service boundaries. createOrder commits in the order service. reservePayment commits in the payment service. allocateStock commits in the inventory service. Each is an ordinary ACID transaction inside one database, and each is durable the instant it commits — nothing is held open pending the outcome of the others.

The cut points are not arbitrary. Each step must be a unit you are willing to have permanently committed while the rest of the saga is still in flight, because that is exactly what will happen.

⚠️ In production — The seam you draw here is the seam your users will see. If you split payment capture from stock allocation, someone will be charged for an item that turns out to be unavailable, and the compensation will be a refund — not a rollback. Design the split around what is acceptable to expose, not around what is convenient to code.


2. Every forward step gets a compensating transaction that semantically undoes it

Undo, not rollback

A compensation is a new transaction that counteracts a committed one. It is not a rollback — the original committed and is in the log forever. reservePayment is compensated by refundPayment. allocateStock is compensated by releaseStock. createOrder is compensated by cancelOrder, which flips a status; it does not delete the row.

Some steps have no meaningful compensation. Sending an email cannot be unsent; the compensation is a second email saying the first one is void. That is a real business decision disguised as an implementation detail.

⚠️ In production — Compensations fail too, and they fail in the middle of an already-failing workflow. A compensation that cannot succeed leaves the saga permanently stuck, so compensations must be retried indefinitely, must be idempotent, and must have an operator escape hatch. Most teams discover the escape hatch requirement during an incident.


3. Steps are ordered so the cheapest-to-undo work happens last

Sequence is a design decision

Saga ordering is not the order the business describes. You want irreversible or expensive-to-compensate steps as late as possible, and cheap reversible reservations early. Reserve inventory before capturing payment, so a stock failure costs you a released reservation rather than a refund and a chargeback fee.

A common shape is reserve-then-confirm: early steps take a soft, expiring hold, and a final step converts all holds into commitments. That collapses most failures into "let the hold expire" instead of "run a compensation chain".

⚠️ In production — Reordering a saga after launch is a data migration, not a refactor — in-flight sagas were persisted under the old order and their compensation chains assume it. Version the saga definition from day one, or you will be unable to deploy a change while any saga is running.


4. Saga state is persisted, so it survives a crash mid-flight

The workflow itself is durable data

A saga that lives only in a running process is lost when that process dies, leaving payments captured and stock reserved with nothing to finish or undo them. So the saga's current step and its state are written to durable storage and advanced by whatever process picks it up next.

The standard mechanism is the transactional outbox: the local business write and the "what happens next" record commit in the same local transaction, and a separate relay publishes them.

BEGIN;
INSERT INTO orders (id, status) VALUES ('o-1', 'PENDING');
INSERT INTO outbox (saga_id, step, payload)
  VALUES ('s-1', 'reserve_payment', '{"order":"o-1"}');
COMMIT;

⚠️ In production — Without the outbox, you get the classic dual-write bug: the database commits and then the process dies before the message is published, so the saga stalls with no record that it should continue. This failure is rare enough to survive months of testing and common enough to happen weekly at volume.


5. Every step and every compensation must be idempotent

At-least-once delivery is the floor

Recovery, retries, and redelivery all mean steps get invoked more than once. Each handler needs a business-level idempotency key — the saga ID plus the step name is usually enough — recorded in the same transaction as the effect it guards.

// key is (sagaId, stepName); the insert is the guard
await db.tx(async t => {
  const claimed = await t.insertIfAbsent('step_log', { sagaId, step })
  if (!claimed) return
  await t.debit(accountId, amount)
})

Idempotency has to reach the downstream system too. If the payment provider supports an idempotency key on its API, pass yours; if it does not, you need your own reconciliation against its records.

⚠️ In production — Out-of-order arrival is the case people miss. A compensation can reach a service before the forward step it is undoing — the network reordered them, or a retry was slow. Handlers must tolerate 'undo something I have not done yet', usually by recording a tombstone that makes the later forward step a no-op.


6. Coordination is either choreography or orchestration

Events between peers, or one controller

In choreography, each service publishes an event and the next service reacts. There is no central component, and no single place that knows the whole flow. In orchestration, one component — the orchestrator — holds the saga definition and issues explicit commands to each participant, which reply with success or failure.

Choreography is lighter for three or four steps with a stable order. Orchestration wins as soon as the flow branches, needs timeouts per step, or someone has to answer "where is order 4471 stuck?" without reading five services' logs.

⚠️ In production — Choreography's cost is invisible at design time and brutal at debug time: the workflow exists only as an emergent property of event subscriptions, so no one can read it. Teams typically start with choreography and migrate to orchestration after the first production incident nobody could trace.


7. Isolation is gone, so anomalies have to be handled in the application

The part that bites in production

A saga gives you atomicity (eventually) and durability, but not isolation. Between step 2 and step 5, other transactions can read and act on partial saga state — a dirty read — or overwrite a value the saga is about to compensate, so the compensation clobbers their change.

The usual countermeasures are semantic locks (a PENDING status other operations refuse to act on), reordering so the risky update is last, and re-reading with a version check inside the compensation.

UPDATE accounts SET balance = balance - 50, version = version + 1
WHERE id = 'a-1' AND version = 7;

⚠️ In production — These anomalies are load-dependent, so they are effectively absent in staging and routine in production. Do not treat 'we haven't seen it' as evidence; treat any state a saga can be observed in as a state your other queries must explicitly handle.


8. Timeouts and dead-letter handling close the open cases

Something must decide when to give up

A participant that never answers is not a failure the saga sees — it is silence. Each step needs a deadline, and expiry has to trigger something explicit: retry, compensate, or park for a human. A saga with no timeout is a saga that leaks stuck instances quietly.

You also need a queryable view of in-flight and failed sagas, and a supported manual path to force-complete or force-compensate. That path will be used.

⚠️ In production — The uncomfortable case is the ambiguous timeout: the request may have succeeded and only the response was lost. Compensating blindly can undo work that never happened, and retrying blindly can double it. Only idempotency keys plus a status query against the participant resolve it — which is why the earlier steps are not optional.

Man managing inventory with tablet in warehouse, focusing on efficiency in storage operations. ## Where You Meet It
  • An e-commerce checkout spanning order, payment, and inventory services. The saga creates the order in PENDING, reserves stock, captures payment, then marks the order CONFIRMED. If capture is declined, the compensation releases the reservation and moves the order to PAYMENT_FAILED — the row stays, because support needs to see why it failed.
  • A travel booking that reserves a flight, a hotel, and a car from three external providers. None of them will join your transaction, and each reservation is separately confirmable and separately cancellable. The saga is essentially forced: cancellation APIs are the compensations, and provider-side hold expiry is your safety net when your own orchestrator dies.
  • Customer onboarding that provisions an account, a billing subscription, and a workspace in a third-party SaaS tool via API. Provisioning the external workspace can fail for reasons you don't control — quota, auth, downtime — and the compensation is deprovisioning the account and cancelling the subscription so you don't bill someone who never got access.
  • A microservice migration where a single monolith transaction was split across two new services. The saga is a transition artefact: it preserves the old operation's end-to-end guarantee while the data lives in two places. Some teams keep it; some find the split was wrong and merge the services back, which deletes the saga entirely.

When You Actually Need It

  • A single business operation writes to two or more databases that no one transaction can span, and a partial outcome is a real customer-visible problem — not just untidy data.
  • At least one participant is an external system you cannot enrol in a transaction: a payment processor, a shipping carrier, a third-party API.
  • Every step already has a meaningful business-level undo — refund, cancel, release, deprovision — that operations staff would recognise and could perform by hand.
  • Steps take long enough that holding locks would be unacceptable: human approvals, batch jobs, provider callbacks measured in seconds or hours.
  • You are already seeing the failure in production — orphaned reservations, charges without orders, tickets that all read 'this got stuck halfway' — and someone is currently fixing them manually.
  • Your service boundaries are stable. Sagas encode the boundary in workflow code, so a boundary you are still moving will make the saga churn.

When You Don't

  • All the data lives in one database. Use a single transaction. This is the most common wrong reason to reach for a saga: teams adopt it because the architecture diagram has boxes, not because the storage is actually split. If it's one database and separate schemas, it's still one transaction.
  • A partial outcome is genuinely tolerable. If the second write is a analytics event, a search index update, or a cache warm, don't build a saga — publish an event, retry it, and let it be eventually consistent with no compensation logic at all.
  • The steps have no honest compensation. If the operation sends money to a stranger, prints a physical label, or launches something, a compensation is fiction. Restructure to reserve-then-confirm so the irreversible act happens exactly once at the end, or keep the operation in one service.
  • Two services, one hop, low volume. Below roughly a few thousand of these operations a day with a two-step flow, a durable job queue with retries plus a nightly reconciliation job will cost you a fraction of the code and catch the same drift. Add reconciliation before you add sagas — you'll need it either way.
  • The team can't yet run it. Sagas require distributed tracing, a queryable saga store, and an on-call runbook for stuck instances. Without those you have not removed the inconsistency, you have hidden it behind async messaging where nobody can see it.
  • You're reaching for it to avoid merging two services. If two services are so coupled that most writes to one require a write to the other, the saga is paying interest on a bad boundary. Merging them is cheaper and removes the problem instead of managing it. Man analyzing design flowchart on whiteboard in a professional office setting.

What People Get Wrong

"A saga gives you the same guarantees as a transaction, just asynchronously."

It gives you atomicity and durability, not isolation or immediate consistency. Between the first commit and the last, other readers see partial state, and there is a real window where money has moved but the order does not exist. That window is a product decision, not an implementation detail.

"Compensation means rolling back."

Nothing rolls back — the original transaction committed and is permanent. A compensation is a forward action with its own row in the history: a refund, not an un-charge. Auditors, customers, and your own reports will see both.

"If we use a workflow engine, sagas are basically handled for us."

A workflow engine handles durable state, retries, and timeouts — genuinely the tedious parts. It cannot write your compensations, decide your step order, or prevent the isolation anomalies. The hard part is the business semantics of undo, and no framework knows those.

"Two-phase commit is the naive option and sagas are the mature one."

They solve the problem under different constraints. 2PC preserves isolation at the price of blocking and a coordinator dependency, and it is a perfectly good answer inside one datacenter, over resources that support it, for short transactions. Sagas trade isolation away because most real systems include participants that will never join a 2PC.

Why It Was Built This Way

The original saga work came out of a database problem, not a microservices one: long-lived transactions. A transaction that stays open for minutes or hours holds locks the whole time, and every other transaction that touches those rows queues behind it. The insight was that if you let a long transaction commit in pieces, and defined a compensating transaction for each piece, you could release the locks immediately and still offer a meaningful all-or-nothing outcome at the business level. The system gives up serialisability; in exchange, concurrency stops collapsing.

That same trade is why the pattern reappeared for distributed services. The alternative, two-phase commit, keeps isolation intact but pays for it with blocking: participants hold locks through the prepare phase, and if the coordinator dies after prepare and before commit, they hold them until it comes back. That is survivable inside one datacenter with cooperating databases. It is not survivable when a participant is a payment processor across the internet — which has no interest in exposing a prepare phase, and would be insane to block on your coordinator's health if it did. Sagas exist because the set of participants stopped being controllable.

What was traded away is the I in ACID, and it is worth being blunt about the size of that bill. Isolation is the property that lets you write application code as if you were alone in the database. Without it, every anomaly the database used to prevent silently — dirty reads, non-repeatable reads, lost updates — becomes a case your code must handle explicitly, and each one shows up under production concurrency rather than in tests. Semantic locks, commutative updates, and version checks are all attempts to buy back a slice of isolation by hand, at the application layer, without the locks. They work, and they are more code than the saga itself. If you would not accept that bill, the correct move is not a better saga framework — it is a data layout where one transaction still covers the operation.

Related Briefs

What to Read Next

  • transactional outbox pattern
  • two-phase commit vs saga
  • eventual consistency in microservices
  • idempotency keys in distributed systems

Reach for a saga when the data is genuinely split across systems you cannot transact over, every step has an undo a human would recognise, and partial state is a customer problem rather than an aesthetic one. If any of those three is false, you're buying isolation loss and a large pile of recovery code to solve a problem a single transaction, a retrying job queue, or a merged service would have solved. The pattern's real cost isn't writing the compensations — it's that every query in the system now has to account for states that used to be impossible.

댓글

이 블로그의 인기 게시물

What Is a Message Queue - and When You Actually Don't Need One

What Is Blue-Green Deployment - and When You Actually Don't Need One

What Is a JWT - and When a Plain Session Is Still the Better Choice