Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 6.7 of 6.8 · Advanced · 30 min read

Distributed transactions, sagas and the outbox pattern

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

When one business action spans several services, no single transaction can cover it. In this lesson you'll learn why two-phase commit is avoided between services, how sagas keep processes correct instead, and how to send messages reliably.

Booking a holiday means booking a flight, a hotel and a hire car, from three different companies. Nobody can make all three happen at exactly the same instant. So you book them one at a time. If the hire car turns out to be unavailable, you cancel the hotel and the flight, perhaps paying a cancellation fee. That's a saga: separate steps, each with a planned way to undo it. The skill is choosing the order of the steps, and planning the cancellations before anything goes wrong.

The classic way to make changes in several databases all-or-nothing is two-phase commit, run by a coordinator. In phase one, it asks each participant whether it can commit. Each makes its changes durable, keeps its locks, and votes yes. By voting yes, it gives up the right to abort on its own. If every vote is yes, the coordinator logs its decision: commit. In phase two, it tells every participant, and they apply the outcome and release their locks. But if the coordinator crashes after the votes, the participants can't decide alone, so they keep their locks until it recovers. Between independently owned services, that's a poor fit. Availability multiplies down, locks are held across the network, deployments become coupled, and many participants, such as a payment provider, can't join at all.

The alternative is a saga: a sequence of local transactions, each in one service's own database, linked by messages. Each step is paired with a compensating transaction that undoes it in a business sense. Either all the steps complete, or the completed ones are compensated in reverse order. What a saga doesn't give you is isolation: between steps, others can see the intermediate state.

Here's the food-delivery order saga, run by the order service as orchestrator. First, it records the order as pending, which tells others the order is in flux. Second, it asks the payment service to authorise payment: a cheap hold on the card, easy to void. Third, it asks dispatch to reserve a courier, the step most likely to fail at busy times. This time, no courier is available. So the orchestrator compensates the completed steps, newest first. It voids the payment authorisation. Then it marks the order rejected, keeping the row for audit, and tells the customer before anything was cooked or charged. Had a courier been found, step four, confirming to the restaurant, would be the pivot. Once a kitchen starts cooking, the saga can only go forward, so later steps are retried until they succeed.

A rollback makes it as if nothing happened. A compensation is a new transaction that counteracts something that did happen, and may have been seen. It can void the card hold, but it can't unsend an email. So compensations must be idempotent, because their trigger may arrive twice, and retriable until they succeed, or raise an alert for a person. They may even arrive before the step they undo. The payment service should then remember the void, and refuse the late authorisation. And steps that can't be undone, such as cooking the food, belong after the pivot, or need a business compensation, such as a voucher or a refund.

Every saga step updates its database and sends a message. Doing those separately is the dual-write problem: crash in between, and the message is lost, or announces something that never happened. The transactional outbox fixes it with one local transaction. The order service writes the order row and the outgoing message, as a row in an outbox table, together. Both commit, or neither does. A separate relay reads the outbox, by polling it or by reading the database's change log, and publishes each message to the broker. A relay can crash before noting what it sent, so a message may go out twice. So the receiver keeps an inbox of processed message IDs, saved with its change, and skips duplicates.

So how do you choose? Prefer one local transaction where you can. If a service split cuts through one consistency boundary, the cheaper fix is often to move the boundary. Use a saga when the steps can be compensated or retried. Choreography, where services simply react to each other's events, suits two or three steps. For longer flows with branching and timeouts, most practitioners prefer an orchestrator. And order the steps deliberately: likely failures before the pivot, and anything irreversible after it.

The full section has the complete step table, and the countermeasures for sagas' lack of isolation. Next, in Section six point eight, we look at serverless and space-based architectures. See you there.

What you need to know

The problem: one business action, several databases

Inside one database, a transaction makes several changes atomic: the order row, its items and the payment reference are all saved or none are (Section 0.5). Once data is split across services that each own their database, as microservices and database per service recommend, a single business action such as placing a food order touches several databases owned by different services: the order service's, the payment service's and the dispatch service's. No single local transaction can cover them all.

There are two families of answer. One is to make many resource managers act as one through a distributed atomic commit protocol, of which two-phase commit is the classic. The other is to accept that the action is a sequence of local transactions and design for the moments in between: the saga. A third answer, often the best, is to notice that the transaction boundary and the service boundary disagree and to move one of them. This section covers all three and ends with a guide for choosing.

Two-phase commit and XA

Two-phase commit (2PC) is a protocol, dating from the late 1970s, that gives all-or-nothing atomicity across several participants such as two databases or a database and a message broker. A coordinator (the transaction manager) runs it. In phase 1, prepare, the coordinator asks every participant whether it can commit. A participant that says yes must first make the changes durable and keep the relevant locks, because by voting yes it gives up the right to abort on its own. If every vote is yes, the coordinator writes its decision, COMMIT, to its own log; if any vote is no or a participant does not answer, the decision is ABORT. In phase 2, commit, it tells every participant the outcome, and they apply it and release their locks.

The weakness is that 2PC is a blocking protocol. A participant that has voted yes is in doubt: it does not know the outcome and cannot safely guess, because another participant may already have committed. If the coordinator crashes at that moment and stays down, the in-doubt participants keep their locks until it recovers, and other transactions touching those rows wait too. Operators can force a heuristic decision to release the locks, at the risk of breaking atomicity.

The XA specification, published by X/Open in 1991, standardised the interface between a transaction manager and resource managers so that, for example, a Java application server can commit a database update and a message send atomically. Many relational databases and some message brokers support it.

Two-phase commit, and where it blocks

Two-phase commit: the coordinator asks each participant to prepare, collects yes votes, logs its decision and then tells each to commitCoordinatorOrders DBPayments DBPhase 1:prepareprepare?prepare?yes (locks held)yes (locks held)logs: COMMITcoordinator crash herePhase 2:commitcommitcommitdonedoneIf the coordinator fails after the votes, participants that voted yes cannot decide alone and keep their locks
Participants that have voted yes hold their locks until they hear the decision; if the coordinator fails at the highlighted point, they are stuck.

Why 2PC is avoided across microservices

2PC works and is still used inside a single system's boundary. Across independently owned services it is usually a poor fit, for reasons that compound each other.

Availability multiplies down. Every participant and the coordinator must be up for any transaction to commit, so the transaction's availability is the product of theirs, the same arithmetic as a synchronous call chain (see why distribute). Locks are held across the network for at least two round trips, and much longer if anything is slow, which cuts throughput and raises contention. Coupling returns: all participants must speak the same protocol, share a transaction manager and agree on timeouts, which undermines the independent deployability that justified splitting the services. Many participants cannot join: most HTTP APIs, third-party services such as payment providers, many NoSQL databases and several popular brokers do not support XA. And the payment provider's API will never hold a lock for your coordinator.

The picture is different inside distributed databases. Systems such as Google Spanner run two-phase commit internally between groups of replicas that are themselves kept consistent by consensus, so a crashed coordinator is replaced rather than awaited. That makes cross-partition transactions within one database practical; it does not make transactions across separately owned services and their different technologies practical.

Sagas: a sequence of local transactions

Hector Garcia-Molina and Kenneth Salem introduced the saga in a 1987 SIGMOD paper about long-lived transactions, ones that would hold database locks for hours. Their idea was to split such a transaction into a sequence of ordinary transactions T1, T2 … Tn, each of which commits on its own, and to pair each with a compensating transaction Ci that semantically undoes it. The system then guarantees that either the whole sequence completes, or the completed steps are followed by their compensations in reverse order: T1 … Tj, Cj … C1.

Microservice architectures adopted the idea because it fits their constraints exactly: each step is a local ACID transaction in one service's own database, and steps are linked by messages. A saga gives atomicity in the eventual, business sense (all steps happen, or the effects are undone), but not isolation: between steps, other transactions can see the intermediate state.

It helps to classify the steps, as Chris Richardson does in Microservices Patterns (2018). Compensatable transactions can be undone by a compensation. The pivot transaction is the go/no-go point: once it commits, the saga will run to completion. Retriable transactions come after the pivot and must be designed so that they eventually succeed if retried. Placing steps that are likely to fail before the pivot, and steps that cannot be undone after it, is a key design decision.

Choreography or orchestration

A saga's steps must be coordinated somehow, and the two options mirror the choreography and orchestration discussed in event-driven architecture.

In a choreography-based saga there is no coordinator. Each service listens for events and reacts by doing its local transaction and publishing a new event: the order service publishes OrderCreated, the payment service reacts and publishes PaymentAuthorised, the dispatch service reacts to that, and so on; failure events trigger compensations in the services that listen for them. It is simple for two or three steps and keeps services loosely coupled. As steps grow, the flow exists only implicitly, scattered across services, so it is hard to see, test and change; and cyclic event dependencies between services creep in.

In an orchestration-based saga a single orchestrator, usually in the service that owns the business process, sends commands to participants and reacts to their replies. The orchestrator is a persistent state machine: it records which step it is on in its own database, so it can resume after a crash. The flow is explicit and testable in one place, and participants need not know about each other. The risk is that the orchestrator accumulates business logic that belongs in the participants. Workflow engines such as Temporal, Camunda and AWS Step Functions provide durable orchestration as a platform, at the cost of another dependency. For more than about three steps, or for any flow with branching and timeouts, most practitioners prefer orchestration.

Worked example: the order saga

The food-delivery platform's order saga is orchestrated by the order service. The order of steps is deliberate: the payment authorisation is a cheap hold that is easy to void, the courier reservation is the step most likely to fail at busy times, and confirming to the restaurant is the pivot, because once a kitchen has started cooking the order cannot simply be undone.

The order saga, step by step

StepService and local transactionKindCompensation if a later step fails
1. Create orderOrder service inserts the order with state PENDING (a semantic lock).CompensatableC1: set state to REJECTED and record the reason. The row is kept for audit, not deleted.
2. Authorise paymentPayment service asks the card provider to place a hold for the total, using the order ID as idempotency key.CompensatableC2: void the authorisation, releasing the hold. The customer may briefly see a pending amount on their banking app.
3. Reserve courierDispatch service reserves a nearby courier for the pickup window.CompensatableC3: release the courier so they can take other jobs.
4. Confirm orderOrder service sets state to CONFIRMED and sends the ticket to the restaurant.PivotNone: from here the saga must go forward. Problems later are handled by new business processes (refunds, vouchers).
5. Notify customer; capture payment on pickupNotification service sends "Order confirmed"; payment service captures the held amount when the courier collects the food.RetriableNone: these steps are retried, idempotently, until they succeed or are escalated to a person.

The order saga when no courier is available

Sequence diagram: an orchestrated order saga in which the courier reservation fails and the earlier steps are compensated in reverse orderOrder servicesaga orchestratorPayment serviceDispatch service1. order PENDING2. AuthorisePaymentAuthorised3. ReserveCourierNoCourierAvailablecompensate completed steps, newest firstC2. VoidAuthorisationVoidedC1. order REJECTEDcustomer told: no courier, not charged
Step 3 fails, so the orchestrator runs the compensations for steps 2 and 1, newest first, and the customer is told before anything was cooked or charged.

Compensation is not rollback

A database rollback makes it as if the transaction never happened: nobody else ever saw its changes. A compensation is a new, forward transaction that semantically counteracts an earlier one that did happen and may have been seen. In the order saga, the customer's bank may already show a pending hold, and an email may have said "payment authorised". The compensation cannot unsend the email; it can only void the hold and send another message. Some actions cannot be compensated at all, such as food already cooked or a notification already read; those belong after the pivot, or need a business compensation such as a voucher or a refund.

Compensations therefore need the same care as forward steps. They must be idempotent, because the message that triggers them may be delivered twice. They must be retriable until they succeed, because a compensation that fails permanently leaves the system in a state nobody designed; if they truly cannot succeed, they must raise an alert for a person to resolve. They may need to be commutative with the forward step: if a VoidAuthorisation arrives before the AuthorisePayment it undoes (possible after timeouts and retries), the payment service should remember the void and refuse the late authorisation rather than fail. And they should be visible to the business: "rejected, no courier" is information the operations team wants on a dashboard.

Living without isolation: anomalies and countermeasures

Because each step commits on its own, sagas lack the I in ACID, and concurrent sagas can interfere. Richardson describes three resulting anomalies. A lost update occurs when one saga overwrites a change made by another without reading it, such as a cancellation saga marking an order cancelled and the order saga then setting it confirmed. A dirty read occurs when a saga reads data another saga has changed but may yet compensate, such as a credit-limit check that counts a hold that is about to be voided. A fuzzy or non-repeatable read occurs when two steps of one saga read the same data and get different values because another saga changed it in between.

He also catalogues countermeasures, design techniques that reduce the anomalies, drawing on earlier research on transactions across multiple databases. The most widely used are these.

Countermeasures for the lack of isolation in sagas (after Richardson)

CountermeasureIdeaFood-delivery example
Semantic lockA compensatable step sets an application-level flag, usually a *_PENDING state, telling other transactions that the record is in flux; they wait, fail or treat it specially.While an order is PENDING, a cancellation request is queued or refused with "order is being processed", not applied halfway through.
Commutative updatesDesign updates so that they give the same result in any order, removing lost updates.The courier's earnings ledger records credits and debits as separate entries rather than overwriting a balance.
Pessimistic viewReorder steps to minimise the business risk of a dirty read, putting risky updates into retriable steps after the pivot.Increase a restaurant's "available capacity" only in a retriable step after cancellation is certain, never in a step that might be compensated.
Reread valueBefore updating, re-read the record and check it has not changed since it was read (optimistic locking); if it has, abort and possibly restart the saga.Before confirming, the orchestrator checks the order's version is still the one it created; if a cancellation changed it, it compensates instead.
Version fileRecord the operations applied to a record so that operations arriving out of order can be reordered or recognised.The payment service records that a void arrived for an authorisation it has not yet seen, and rejects the late authorisation.
By valueChoose the coordination mechanism per request by its business risk.Ordinary orders use the saga; a high-value corporate catering order might go through a stricter, slower process.

Publishing reliably: the transactional outbox

Every saga step does two things: it updates its own database and it sends a message (an event or a reply). Doing them separately creates the dual-write problem. If the service commits and then crashes before publishing, the rest of the saga never hears about the step. If it publishes first and the commit then fails, other services act on something that never happened. Wrapping the database and the broker in 2PC is exactly what we wanted to avoid.

The transactional outbox solves it with one local transaction. The service writes the business change and the outgoing message, as a row in an outbox table, in the same database transaction; either both commit or neither does. A separate relay then reads the outbox and publishes each message to the broker. Two relay designs are common. A polling publisher queries the outbox table for unpublished rows every so often and marks them sent: simple, but it adds load and latency. Transaction log tailing uses change data capture (CDC): a tool reads the database's own replication log (the PostgreSQL write-ahead log or the MySQL binary log) and turns new outbox rows into messages. Debezium, an open-source CDC platform usually run with Kafka Connect, is the best-known example and includes an outbox event router that routes each row to a topic based on a column such as the aggregate type.

The relay can crash after publishing and before recording that it did, so it will sometimes publish a message twice. The outbox therefore gives at-least-once delivery, and consumers must cope with duplicates. Publishing order is preserved per aggregate if the relay publishes rows in commit order and uses the aggregate ID as the message key.

BEGIN;

INSERT INTO orders (id, customer_id, state, total_pence)
VALUES ('1042', 'c-77', 'PENDING', 2350);

INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload)
VALUES ('e-9f1c', 'order', '1042', 'OrderCreated',
        '{"orderId":"1042","customerId":"c-77","totalPence":2350}');

COMMIT;
-- A relay (poller or CDC such as Debezium) later publishes e-9f1c to the broker.
Placing an order with a transactional outbox: the order and its event commit together, in one local transaction.

Outbox on the way out, inbox on the way in

The transactional outbox: the order and its event are written in one local transaction, a relay publishes the event, and the consumer de-duplicates with an inboxOrder serviceplace order: one local transactionordersrow 1042outboxOrderPlacedboth rows commit,or neither doesRelaypoller or CDCreadsBrokertopic: orderspublishesDispatch serviceinbox: seen IDsat-least-once delivery;duplicates skipped
The outbox removes the dual write on the sending side; the inbox, or any duplicate check, makes the receiving side safe under at-least-once delivery.

Receiving reliably: idempotent receivers and the inbox

Because the outbox, broker retries and saga retries all produce duplicates, every participant must be an idempotent receiver, the name Gregor Hohpe and Bobby Woolf gave the pattern in Enterprise Integration Patterns (2003). Some operations are naturally idempotent ("set order 1042 to CONFIRMED"); others need a duplicate check. The usual implementation is an inbox: a table of processed message IDs, written in the same local transaction as the business change, so that "process the message" and "remember that it was processed" commit together. A redelivered message finds its ID already present and is acknowledged without doing anything. The consumer-side code appears in event-driven architecture.

Together, outbox, at-least-once delivery and idempotent receivers give the practical result people usually mean by "exactly once": each business effect happens once, even though messages may travel more than once.

Decision guide

Decision guide: one transaction, a saga, or new boundaries?

Use this when a business action needs to change data in more than one place.

  1. Is all the data that must change together owned by one service and stored in one database?
    YesUse one local ACID transaction. It is the simplest and strongest option; do not distribute it.
    NoGo to the next question.
  2. Do the parts that must change together nearly always change together, belong to one team and share a bounded context, so that the split is accidental?
    YesRedesign the boundary: merge the services, or move the data, so the action becomes one local transaction. Sagas are not a fix for a wrong split.
    NoGo to the next question.
  3. Must every participant see all changes at the same instant, with no intermediate state ever visible (for example, a ledger that must always balance)?
    YesKeep those records in one database, or use a distributed database that provides cross-partition transactions internally. Use 2PC only inside one system's boundary, with participants you control.
    NoGo to the next question.
  4. Can every step either be compensated, or be placed after a pivot and retried until it succeeds?
    YesUse a saga: orchestrated if it has more than about three steps or any branching, choreographed if it is short and stable. Add an outbox, idempotent receivers and countermeasures such as semantic locks.
    NoReorder the steps or change the business process (for example, reserve before charging, or add a manual review) until it can; otherwise reconsider the boundaries.

Check your understanding

Why can a participant in two-phase commit not simply commit on its own if the coordinator disappears after it voted yes?

Because it does not know the decision: another participant may have voted no, so the coordinator may have decided to abort; guessing could break atomicity.

In the order saga, why is confirming the order to the restaurant the pivot rather than the first step?

Once the kitchen starts cooking, the step cannot be undone, so it must come after the steps that might fail (payment authorisation, courier reservation), which can be compensated cheaply.

What problem does the transactional outbox solve, and what guarantee does it give?

It removes the dual write between the database and the broker by committing the change and the message together; the relay then delivers the message at least once, so duplicates are possible.

Name two ways a compensation differs from a database rollback.

It is a new transaction that runs after the original committed, and others may already have seen or acted on the original; it may also be only a semantic undo (a refund, a void) rather than restoring the exact previous state.

Next · 6.8Serverless and space-basedTwo opposite answers to capacity: serverless functions and managed back ends that scale to zero and bill per use, and space-based architecture that serves extreme peaks from replicated memory with the database off the hot path.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “Our checkout calls payment, then stock, then shipping, and sometimes we charge customers for items we cannot ship. How should we redesign it as a saga?”