Primary Sources
Specifications and documentation behind the claims on this page
- Garcia-Molina and Salem, “Sagas” (ACM SIGMOD, 1987) — the original paper (no link given)
Core Concepts
Maintain business consistency across multiple services without distributed transactions.
Keeping a business operation coherent when no single transaction can span it
A saga coordinates one business operation that spans several services, as in a microservice architecture where each service owns its data and commits its own local transaction. If a later step fails, the saga runs compensating actions, new business operations that counteract the effect of the steps that already committed. The goal is to end in a valid business state, either completed or cleanly compensated. It does not give you the all-or-nothing visibility and isolation of one ACID transaction.
The pattern exists because a single transaction cannot reach across independently owned databases and third-party APIs. A hotel booking has to hold a room in an inventory database, authorize a card with an external payment provider, and confirm the reservation. Nothing lets a database commit and a card network commit together, so the operation is always, for some period, partly done. A saga makes that period explicit: it records how far the operation got, what was committed, and what to do from there.
The mental model: a saga is a durable to-do list with an undo plan for every item. The list survives crashes because it is stored, each item can be repeated safely because it is idempotent, and when an item fails for good the plan says how to reach a state the business accepts. Three kinds of questions follow from that, and the rest of this lesson answers them in order. What does each step commit, and how is it countered (compensation)? Who remembers where the operation is (the coordinator and its state)? What can other operations see in the meantime (isolation)?
Take the running example. A guest books a room. The saga holds the room, authorizes the payment, confirms the booking, and emails the guest. If the payment is declined, the saga releases the hold. If the payment call times out, the saga must not assume either outcome. It has to find out what actually happened, because releasing the room while the card was charged, or charging again while the first charge exists, are both business failures. Saga papers date to Garcia-Molina and Salem (1987), who proposed breaking long-lived transactions into sub-transactions with compensations; the microservices form is the same idea with networks in the middle.
Distributed transaction → step failure → compensation → eventual business consistency
⤢ Expand for synchronized narration timeline
The normal path and the two failures every design has to handle
Four participants take part. Booking owns the workflow and the saga record. Inventory holds and confirms rooms. Payment talks to a card provider. Notification emails the guest. Each owns its data; none can join another's transaction. The reservation reference booking_817 is created once, at the start, and travels with every command so that every participant can recognise a repeat.
Reading the marks. ✔ a local transaction committed in that service · ✘ a definitive failure · ↩ a compensating operation, which is a new committed transaction, not an undo. Dashed arrows are replies.
Normal path. Hold, authorize, confirm, notify. Every arrow marked ✔ is a local transaction that has already committed in its own service; there is no moment at which all four exist together in one transaction. The confirmation is where the booking becomes real to the hotel, and later sections treat it as the point after which the saga only moves forward. The authorization is only a hold on the guest's funds; the capture that actually charges the card is a retriable step that runs after the pivot, so it is not drawn in the figure.
Payment declined. The provider answers definitively that the card was not authorized. The only committed effect is the room hold, so the compensation is a single ReleaseHold. Notice the compensation is itself a command with its own acknowledgement. The saga is not finished when it sends it, but when Inventory confirms the release, so the state RELEASING_HOLD exists between the decline and COMPENSATED.
Ambiguous timeout. The authorization committed at the provider, but the reply never arrived. From Booking's side this looks identical to a request that never arrived. A timeout is the absence of information, not a failure. Booking records PAYMENT_OUTCOME_UNKNOWN, asks the provider what happened to booking_817, and proceeds from the answer. Applying a failure handler here, releasing the room and telling the guest the booking failed, would leave an authorization on the card for a booking that does not exist. The pattern of "ask, don't guess" recurs in Retry & Backoff, which covers why timeouts are ambiguous in the first place.
A compensation is a new transaction, not a rewind
A database rollback discards changes that were never committed, so no other transaction ever saw them. A saga step has already committed and may have been observed, acted on and replicated. Its compensation is a separate business operation that attempts to counteract the earlier effect. The earlier effect remains in the history; the compensation adds to it.
That difference shows up in everything a compensation can and cannot do. Releasing a room hold restores capacity cleanly, because the hold was only a claim. A card authorization can usually be voided, which frees the held funds without a charge ever settling. A payment that was captured is different: the customer can see both a charge and a refund, fees may not come back, and settlement takes days. An email cannot be unsent, so the closest compensation is a second email, which is itself a visible event. Design by asking what an outside observer can see, not what the database says.
| Forward step | Compensation | What stays visible afterwards |
|---|---|---|
| Hold a room or inventory unit | Release the hold | Nothing external, if the hold never left your system. A displayed "sold out" seen meanwhile cannot be recalled. |
| Authorize a card | Void the authorization | A pending line on the cardholder's statement can linger until the issuer releases it. |
| Capture a payment | Refund | Both the charge and the refund appear; fees and settlement timing may not reverse. Prefer authorizing first and capturing late. |
| Reserve a seat, apply a coupon, deduct points | Cancel, restore, credit back | Balances are restored but other customers may have been refused in between, and a coupon may have expired. |
| Send a confirmation email | None; at best a correction email | The original message. Treat sending as irreversible and do it late. |
The practical consequence is that every step needs more than a forward action. It needs an outcome that can be queried (so ambiguity can be resolved), an idempotency strategy (so repeats are harmless), and where one exists, a compensation that is itself safe to repeat. The Idempotency lesson covers how keys and result storage make that work; a saga uses the same mechanism at every hop.
Order steps by how reversible they are, and decide where the saga stops going backwards
Not every step deserves the same treatment. Sagas are easier to reason about when each step is one of three kinds. A compensable step has a compensation and can be undone: the room hold, the card authorization. A pivot step is the go/no-go point. If it commits, the saga will run to completion; if it fails, the saga compensates everything before it. A retriable step comes after the pivot, must eventually succeed, and is simply repeated until it does: capturing the payment, sending the email.
This gives the saga two recovery directions. Backward recovery runs compensations for the committed steps in the opposite order and is available only before the pivot commits. Forward recovery keeps retrying the remaining steps and is the only option after it. Which direction applies to a failure depends on where the failure lands relative to the pivot, not on how severe it feels. In the booking design here the pivot is confirming the reservation with the hotel: once it commits, a failed email or a slow capture does not undo the booking.
Ordering follows. Put steps that are easy to undo and likely to fail early, so most failures compensate only cheap things. Put the irreversible, externally visible steps (emails, shipments, captures) after the pivot, so they are only ever executed in sagas that will complete. Sending a confirmation email in step two of five means every later failure produces a customer who has been told yes and is about to be told no.
Pick each failure in turn. A sold-out room ends the saga with nothing to undo because no step had committed. A declined payment compensates one step; a rejected confirmation compensates two, in reverse. Failures after the pivot never compensate: the capture and the email are retried until they succeed, with the same idempotency key every time, and escalate to a person when a retry can never succeed, such as a card that cannot be captured after the booking is already confirmed. Whether retries should run in the request or from a durable queue with dead-letter handling is a policy choice, but the saga's state has to record that a retriable step is pending.
Who knows the whole workflow: one coordinator, or nobody
There are two ways to drive the steps. In an orchestrated saga a coordinator owns the workflow state. It sends a command to a service, waits for the outcome, decides the next command, and starts compensation when something fails. The flow is written down in one place, which makes it readable, testable and observable. The price is that the coordinator's durable state and availability now have to be engineered. It should hold sequencing and decisions, not business rules that belong to the services, or it turns into a god service everyone must change.
In a choreographed saga there is no coordinator. Each service reacts to events: Inventory publishes InventoryHeld, Payment reacts by authorizing and publishes PaymentAuthorized, Booking reacts by confirming. A failure is another event: PaymentFailed makes Inventory release the hold. Services stay loosely coupled and there is no central component to scale or lose.
The cost of choreography is that the workflow exists only as the sum of subscriptions. With three steps this is comfortable. With ten steps and a handful of exceptional branches, answering "why was this room released?" means reading every service's handlers. Three failure modes are typical. A compensation event is published and nobody subscribes, so the inventory stays held. Services end up in cyclic dependencies because each reacts to another's events. And no one can say whether a saga is stuck, since no component is responsible for noticing. Mitigations exist: put a correlation id (the saga id) on every event, build a read model that tracks each saga from the event stream, and set timeouts explicitly. The read model is, in effect, a coordinator that only observes.
Neither style is categorically better. Choreography fits short, stable flows owned by one or two teams, where the events are useful to other consumers anyway. Orchestration fits flows with branches, timeouts, human approval, or many compensations, and flows where the people debugging need one place to look. Mixed designs are common: an orchestrator inside one bounded context emits events to others. A workflow engine can provide the orchestrator's durable state, timers and retries instead of hand-built tables; Temporal, AWS Step Functions and Camunda are examples of that approach. The Pub/Sub and Queues & Workers lessons cover the delivery mechanics both styles depend on.
One distinction helps in either style. A command tells a specific service to do something and expects a reply (HoldRoom). An event states a fact that already happened and does not care who listens (InventoryHeld). Orchestration is built from commands and replies; choreography from events. Mixing the two vocabularies inside one style, say an event named PleaseReleaseHold, is a sign that the design has not decided who is in charge.
Persisted progress is what makes recovery possible
A robust saga has an identifier and a persisted state record, and every change to that record is a legal transition. The booking's forward path is CREATED → INVENTORY_HELD → PAYMENT_AUTHORIZED → BOOKING_CONFIRMED → COMPLETED. A known payment failure leads to RELEASING_HOLD and then COMPENSATED. An ambiguous response leads to PAYMENT_OUTCOME_UNKNOWN and RECONCILING. The state names matter more than they look: they are what a recovery worker, an alert and a support engineer will read.
Two details of the diagram carry the design. RECONCILING has two exits: finding the authorization returns the saga to PAYMENT_AUTHORIZED and finding none leads to compensation, so ambiguity is always resolved by evidence and never by a timer alone. And a failure after authorization, for example the hotel definitively rejecting the confirmation, passes through VOIDING_PAYMENT before the hold is released, because the order of compensations follows what each one depends on (see below). Compensating states such as RELEASING_HOLD are real states with real retries, not instantaneous.
If the provider processed the request and the response was lost, issuing a new payment with a different idempotency key charges the card twice. A recovery worker should instead query the provider using the original payment reference, or repeat the request with the same provider-supported idempotency key, within the provider's documented retention window for such keys. Providers differ on how long keys are honoured and on whether a lookup by reference exists, so both belong in the integration design and tests, not in assumptions.
The saga record needs enough to resume: identity, type, state, a version for concurrency control, and the context the remaining steps need (the quote accepted, the hold id, the payment reference). A scheduling field lets a worker find sagas that are due for another look.
-- PostgreSQL. Runnable as written (needs 9.5+ for ON CONFLICT, jsonb needs 9.4+).
CREATE TABLE saga_instance (
saga_id text PRIMARY KEY, -- e.g. 'booking_817'
saga_type text NOT NULL,
state text NOT NULL,
version integer NOT NULL DEFAULT 0,
context jsonb NOT NULL, -- quote id, hold id, payment reference ...
next_action_at timestamptz, -- when a recovery worker should look again
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE saga_outbox (
id bigserial PRIMARY KEY,
saga_id text NOT NULL REFERENCES saga_instance (saga_id),
command_id text NOT NULL UNIQUE, -- deterministic per saga step, used for dedupe
destination text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz
);Two workers can pick up the same saga: a message is redelivered, or a recovery worker races the normal reply path. Make every transition a compare-and-set on (state, version). The loser sees zero rows updated, re-reads, and discovers the work is already done. That is deliberate optimistic concurrency on the saga row, and it is usually all the mutual exclusion a saga needs. Reaching for a lease or a distributed lock here brings its own hazards, because a paused worker can still act after its lease expired; see Distributed Locks for leases and fencing tokens, and why the check belongs at the resource being protected, which here is the saga row.
# Illustrative pseudocode, not a library API.
# transition(saga, from_state, to_state, outgoing) is the single transaction shown above:
# compare-and-set on (state, version) plus the outbox insert.
on PaymentReply(saga_id, reply):
saga = load(saga_id)
if saga.state != INVENTORY_HELD and saga.state not in (PAYMENT_OUTCOME_UNKNOWN, RECONCILING):
return # duplicate or stale reply: ignore
if reply == AUTHORIZED:
transition(saga, to=PAYMENT_AUTHORIZED, send=ConfirmBooking(saga.id))
elif reply == DECLINED: # a definitive answer, nothing was charged
transition(saga, to=RELEASING_HOLD, send=ReleaseHold(saga.id))
elif reply == TIMEOUT: # NOT a failure
transition(saga, to=PAYMENT_OUTCOME_UNKNOWN, next_action_at=now() + 30s)
every 30 seconds: # recovery worker
for saga in due(states=[PAYMENT_OUTCOME_UNKNOWN, RECONCILING]):
result = payment.query(reference=saga.id) # or retry with the same idempotency key
if result.found: transition(saga, to=PAYMENT_AUTHORIZED, send=ConfirmBooking(saga.id))
elif result.not_found and policy_allows_giving_up(saga):
transition(saga, to=RELEASING_HOLD, send=ReleaseHold(saga.id))
else: reschedule(saga, backoff(saga.attempts)) # still unknown, keep goingNot runnable. It shows the decision structure: stale replies are ignored, a timeout becomes a state rather than an error, and recovery queries evidence before choosing a direction.
The coordinator can crash between any two lines; every step must still be safe
A saga's progress has two parts: the new state in its store and the next command sent to a service. If they are separate operations, a crash between them corrupts the saga. Commit PAYMENT_AUTHORIZED and crash before publishing the confirm command, and the saga sits in that state forever with nothing driving it. Publish first and crash before committing, and after restart the saga is still in the old state and publishes again.
The usual fix is a transactional outbox: write the state change and the outgoing command to the same database in one local transaction, and let a separate relay publish committed rows. That removes the lost-command case and leaves one deliberate one, a duplicate when the relay crashes after publishing but before marking the row sent. The Outbox Pattern lesson owns the relay mechanics; the saga-specific part is below.
-- One local transaction: advance the state AND record the next command.
BEGIN;
UPDATE saga_instance
SET state = 'PAYMENT_AUTHORIZED', version = version + 1, updated_at = now()
WHERE saga_id = 'booking_817'
AND state = 'INVENTORY_HELD'
AND version = 1;
-- Application: expect exactly one row. Zero rows means another worker already advanced this
-- saga, or this is a replayed reply. ROLLBACK, re-read the saga, and decide from its real state.
INSERT INTO saga_outbox (saga_id, command_id, destination, payload)
VALUES ('booking_817', 'booking_817:confirm', 'inventory',
'{"op": "ConfirmBooking", "bookingId": "booking_817"}');
COMMIT;
-- Separate relay process: publish unsent rows, then mark them sent.
-- A crash between publish and UPDATE means the row is published again later (at-least-once).
SELECT id, destination, command_id, payload
FROM saga_outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;Duplicates are therefore expected, which makes consumers responsible for idempotency. Give each command a deterministic id derived from the saga and the step, such as booking_817:confirm. The service records the ids it has applied, in the same transaction as its own change, and answers a repeated id with the original outcome. This applies to compensations as much as to forward steps. A refund that is retried after a transient failure and succeeds twice is the same bug as a double charge. Compensations that fail permanently go to a retry-with-backoff policy, and once retries are exhausted to a dead-letter queue and an operator, with the saga marked as needing manual intervention rather than quietly abandoned.
Reverse order is a good default, not a law
Compensations usually run in the reverse order of the completed steps, because later actions often depend on resources created by earlier ones. You do not want to release a room while a payment that references it is still live and a guest might be told the booking exists. Reverse order is a heuristic, though. The correct sequence depends on business invariants and on which effects are irreversible. A cancellation may need an authorization reversed before inventory is released, or the opposite, depending on contract terms and on how a failure halfway through would be repaired. For a workflow that sends an email, reserves inventory and issues a refund, reverse order may be wrong simply because the email cannot be unsent and the others can.
Define each compensation alongside its forward action during design, not after the first incident. For every pair ask a short list of questions. Can the compensation fail, and what happens then? Is it idempotent? How long may it be retried before a person looks? Can it run when the forward action has not arrived yet? The last question is the one most designs miss. Messages can be reordered or delayed, so a ReleaseHold may reach Inventory before the HoldRoom it cancels. If the release finds nothing and returns success, the late hold then takes effect and nothing ever releases it. The robust form records a tombstone for the reservation id, so the late forward command is refused. The SQL under isolation below shows one.
A compensating transaction may itself need a saga-like retry-and-escalation policy. The recurring failure shapes are worth designing against explicitly:
Sagas keep atomicity, consistency and durability in a weaker sense, and give up isolation
Sagas are often summarised as ACID without the I. Eventually every step completes or is compensated (atomicity in a business sense), each local transaction respects its own invariants, and the outcomes are durable. What is gone is isolation: while a saga is in flight, its intermediate effects are committed and visible to every other transaction and saga. Other work can read them, act on them, or overwrite them, and then the saga may compensate.
The first scenario is the classic lost update. Two customers start booking the last room. Inventory reads "one available" for each, and then each decrements it. If the read and the decrement are separate steps with nothing between them, both sagas proceed and one room is promised twice. Compensation is not a substitute for protecting a local invariant: it would give one customer their money back after the apology email, but the oversell was avoidable. The guard belongs in the service that owns the data, as a single atomic conditional update with a stable reservation id and an expiry.
-- PostgreSQL. Runnable as written; the branching comments describe application logic.
CREATE TABLE room_night (
room_type text NOT NULL,
night date NOT NULL,
total integer NOT NULL,
held integer NOT NULL DEFAULT 0,
PRIMARY KEY (room_type, night),
CHECK (held BETWEEN 0 AND total)
);
CREATE TABLE room_hold (
hold_id text PRIMARY KEY, -- the saga's stable reservation id
room_type text NOT NULL,
night date NOT NULL,
status text NOT NULL CHECK (status IN ('HELD', 'CONFIRMED', 'RELEASED')),
expires_at timestamptz NOT NULL
);
-- Forward step: HoldRoom. Idempotent on hold_id, guarded against overselling.
BEGIN;
INSERT INTO room_hold (hold_id, room_type, night, status, expires_at)
VALUES ('booking_817', 'deluxe', '2026-11-02', 'HELD', now() + interval '15 minutes')
ON CONFLICT (hold_id) DO NOTHING;
-- 0 rows inserted: a retry, or a release arrived first. Re-read room_hold.status, answer from it, stop.
UPDATE room_night
SET held = held + 1
WHERE room_type = 'deluxe' AND night = '2026-11-02'
AND held < total;
-- 0 rows updated: sold out. ROLLBACK (this also removes the hold row) and reply SOLD_OUT.
COMMIT;
-- Compensation: ReleaseHold. Idempotent, refuses to undo a confirmed booking.
BEGIN;
UPDATE room_hold SET status = 'RELEASED'
WHERE hold_id = 'booking_817' AND status = 'HELD'
RETURNING room_type, night;
-- 1 row returned: give the capacity back (next statement). 0 rows: already released, confirmed,
-- or the hold never arrived. In the last case record a tombstone so a late HoldRoom is refused:
-- INSERT INTO room_hold VALUES ('booking_817', 'deluxe', '2026-11-02', 'RELEASED', now())
-- ON CONFLICT (hold_id) DO NOTHING;
UPDATE room_night SET held = held - 1
WHERE room_type = 'deluxe' AND night = '2026-11-02'; -- run only when the UPDATE above returned a row
COMMIT;The CHECK constraint and the held < total predicate each stop overselling independently. The reservation id makes retries idempotent, and the tombstone refuses a hold that arrives after its own compensation.
The second scenario survives a perfectly guarded update. Saga A holds the last room. Saga B is correctly told that nothing is left. Then A's payment is declined and the hold is released, so a room existed after all, but B has already told its guest otherwise. This is a dirty read of a business state that was never final. A saga cannot prevent it by locking, because there is no cross-service lock. It is handled by designing the visible states, using what are called countermeasures:
| Countermeasure | Idea | Booking example |
|---|---|---|
| Semantic lock | Mark a record as being changed by an unfinished saga; others wait, fail or branch on the flag. | Report the room as HELD, not sold. B offers a waitlist instead of "sold out". The flag is cleared by confirmation or release. |
| Commutative updates | Design operations whose order does not matter, so interleaving cannot lose an update. | Increment and decrement a counter, never write an absolute available = 0. |
| Pessimistic view | Reorder steps so that a dirty read is least harmful. | Perform the step that risks being compensated (the hold) first and the step that others would act on last. |
| Reread value | Before overwriting, read the value again and abort if it changed. Optimistic concurrency. | Update the price or quote only if its version still matches what the saga accepted. |
| Version file | Record each operation so out-of-order compensations can be reconciled. | Keep a per-reservation log so a late ReleaseHold is recognised against its hold. |
| By value | Choose the mechanism per request by business risk. | Low-value holds use the cheap path; high-value or contested ones take a stricter semantic lock. |
The right amount depends on cost. A dirty read that shows a customer "sold out" for a few seconds may be an acceptable lost sale. One that lets a trade settle against funds that are later reversed is not.
Long-running sagas meet changing prices, expired holds, and updated permissions. Record the quote or version the customer accepted when the saga starts, and decide in advance whether the saga may reprice or must fail. Give holds an explicit expiry so an abandoned saga cannot keep capacity forever, with a sweeper that releases expired holds through the same idempotent release path. And keep two kinds of timeout apart in the code and in the state names. A transport timeout means a call did not return, and the outcome is unknown. A business expiry means the deadline for the deal has passed, and the saga should stop and compensate. Treating the first as the second compensates things that did happen. Treating the second as the first retries a hold that no longer makes sense.
Atomic commit with blocking, or short local transactions with visible intermediate states
Two-phase commit (2PC) coordinates transactional resource managers through a prepare phase and a commit phase. Each participant durably promises it can commit and keeps its locks while it waits; the coordinator logs a decision and tells everyone. Within the model it supports, the result is atomic commit. The costs are structural. A participant that has voted yes cannot decide alone, so if the coordinator fails after the votes the prepared participants block, holding locks, until recovery. Every participant must implement the protocol, which excludes arbitrary HTTP APIs and external payment providers, and every participant is operationally coupled to the others. Some databases use 2PC internally across their own partitions, with the coordinator made highly available; that is a different proposition from spanning independent products and vendors.
A saga never holds a cross-service transaction open. Each local transaction commits quickly, and the consistency between them is repaired afterwards. The price is the one this lesson has spent most of its length on: intermediate states are visible, compensations are semantic rather than physical, and isolation is weaker. A saga does not give external observers all-or-nothing visibility; it gives them a business process that eventually converges.
The decision starts with the boundary. If all the data lives in one database and one transaction can enforce the invariant, use that transaction. It is simpler and stronger than any saga. Reach for a saga when the workflow spans independently committed systems you cannot or should not enlist in a shared transaction, and when the business can define what "compensated" means for each step.
A constructed scenario: walking one ambiguous failure through recovery, and what to monitor
The booking saga is in INVENTORY_HELD. It calls the payment provider with the reference booking_817, and the provider authorizes the charge. The network fails before the response arrives. The orchestrator records PAYMENT_OUTCOME_UNKNOWN, not a failure. A reconciliation worker then queries the provider for booking_817:
PAYMENT_AUTHORIZED and proceeds to confirmation.The timeline separates two bounds that are easy to conflate. The hold's expiry bounds the inventory risk: after 900 seconds nobody is blocked by a room nobody will pay for. It does not bound the payment ambiguity. The authorization may exist at the provider long after the hold is gone, so the saga must keep reconciling until it learns the outcome, and then void the authorization instead of confirming a booking whose room was released. Stopping at hold expiry and marking the saga failed is how orphaned authorizations appear.
Track the age of sagas by state and alert when a state exceeds its threshold. Also watch the compensation success rate, the count of ambiguous outcomes and how long they take to resolve, the duplicate command rate, and mismatches found by business reconciliation. Prefer alerts that state a business invariant over alerts on exceptions: "an authorized payment without a confirmed booking for 15 minutes" finds a stuck saga that no exception ever fired for. Put the saga id on every log line, trace span and message so one operation can be followed across services; with choreography this is the only way to reconstruct the flow.
Partial completion has to be unavoidable and compensation has to be definable
Use a saga for multi-step orders, bookings, fulfilment, subscription changes and other workflows that span services where partial completion cannot be avoided and the business can state what a compensation is. A good saga design makes intermediate states explicit to engineers and, where it matters, to users.
| Situation | Verdict | Reason |
|---|---|---|
| Order, payment, inventory and shipping in separate services | Good fit | The canonical case: independent commits, defined compensations, long-ish duration. |
| Travel or hotel booking with several suppliers | Good fit | Partial bookings must be released explicitly, and supplier calls are not transactional. |
| Money movement across systems | Possible, with care | A saga does not guarantee both legs happen or neither does at any instant. Prefer one ledger and one transaction where possible, and add reconciliation either way. |
| All data in one database | Do not use | A normal transaction is simpler and stronger. |
| Publishing an event with no undo logic | Overkill | If nothing needs compensating, a pub/sub or queue is enough. |
| Steps with no defined compensation and no pivot | Redesign first | Without a way to reach a valid end state, the saga only automates the mess. |
The mechanisms a saga is built from
The first thing to get right is the step-level guarantees: the Idempotency lesson covers keys and stored results, and Retry & Backoff covers how to repeat a step without making an outage worse. To make state and commands commit together, read the Outbox Pattern; for stuck compensations, Dead-Letter Queues. When a saga needs exclusive ownership of a resource, Distributed Locks explains leases and fencing and why they are not a substitute for an invariant. Sagas often produce the events that feed CQRS read models and an Event Sourcing log, which are independent ideas that combine well with them.
Specifications and documentation behind the claims on this page