Learn why transactions across services are hard, how two-phase commit blocks, and how sagas, the outbox pattern and idempotency deliver reliable outcomes.
What you'll learn: By the end of this lesson you will be able to explain why a single ACID transaction cannot span several services, describe how two-phase commit works and where it blocks, design a saga with compensations, and use the transactional outbox and idempotency to get reliable "exactly-once effects".
Picture planning a holiday through three separate agencies: one books your flight, one your hotel, one your car. Each agency keeps its own ledger. You cannot make one phone call that atomically books all three. If the flight and hotel succeed but the car agency is out of stock, someone has to cancel the first two, and cancelling is itself a booking that might fail.
Inside one database, a transaction hides that mess for you: either everything happens or nothing does. Once your data is split across services, each with its own database, that guarantee disappears. This lesson shows what you can build in its place, what each option costs, and how to reason about the failures in between.
The core idea
Inside one database, atomicity is a feature you get for free. Across services, it is a protocol you must design.
You have two broad strategies:
Coordinate atomically. Make all participants agree to commit or abort together (two-phase commit). Strong guarantee, but it blocks when something fails and couples every participant's availability.
Accept intermediate states. Run a sequence of local transactions, and if a later step fails, undo earlier ones with compensating actions (sagas). The system is eventually consistent: for a while, the world is partly updated.
Many service architectures choose the second strategy for business workflows, and use the first only inside a single system that was built to support it.
Why transactions across services are hard
Suppose an order service, a payment service and an inventory service each own a database. Placing an order means:
Reserve stock.
Charge the card.
Create the order record.
What can go wrong:
A step fails cleanly. The card is declined. Easy to detect, but step 1 already reserved stock.
A step fails ambiguously. The call to the payment service times out. Did it charge the card or not? The caller cannot know.
A service crashes between its database commit and its reply. The work is done, but nobody knows.
A message is delivered twice. Retrying is safe only if the receiver can recognise the duplicate.
IMPORTANT
The ambiguous failure is the heart of the problem. On an unreliable network, a timeout means "I do not know", not "it failed". Every design in this lesson is an answer to that uncertainty.
Two-phase commit and its blocking problem
Two-phase commit (2PC) is the classic atomic protocol. A coordinator drives several participants through two phases.
sequenceDiagram
participant C as Coordinator
participant A as Participant A
participant B as Participant B
C->>A: Prepare
C->>B: Prepare
A->>C: Vote yes
B->>C: Vote yes
Note over C: Decision logged durably
C->>A: Commit
C->>B: Commit
A->>C: Ack
B->>C: Ack
Phase 1 (prepare). The coordinator asks each participant "can you commit?" Each participant does the work, writes it durably, locks the affected rows, and votes yes or no. A yes is a promise: "I can commit and I will not change my mind."
Phase 2 (commit or abort). If every vote is yes, the coordinator logs the decision and tells everyone to commit. If any vote is no, everyone aborts.
This delivers real atomicity. The cost is the blocking problem. After a participant votes yes, it cannot decide on its own. It must wait for the coordinator. If the coordinator crashes after collecting votes but before sending the decision, participants hold their locks and wait, possibly for a long time, until the coordinator recovers and its log is read. Other transactions needing those rows are stuck too.
Other costs:
Latency. Two round trips minimum, plus durable writes at each step.
Lower availability. The transaction succeeds only if the coordinator and all participants are up. Availability multiplies, so more participants means more chances of failure.
Lock duration. Locks are held across network round trips, which limits throughput under contention.
Heterogeneous systems. Every participant must support the protocol. Many NoSQL stores, message brokers, and third-party APIs do not.
Protocols such as three-phase commit try to reduce blocking but depend on assumptions about timing that real networks do not guarantee, and they are rarely used in practice. Some distributed SQL databases run an atomic commit protocol internally, combined with consensus-replicated participants, so a single node failure does not stall a transaction. That works because the whole system is designed as one unit. See Consensus and Leader Election for the machinery underneath.
Approach
Strength
Weakness
Use when
Local ACID transaction
Simplest, strongest
One database only
Data fits one service boundary
Two-phase commit
Atomic across participants
Blocking, latency, coupling
A few tightly controlled, transaction-capable stores
Saga
Available, loosely coupled
Intermediate states, compensation logic
Business workflows across services
Redesign boundaries
Removes the problem
Needs refactoring
Two services always change together
flowchart TD
A{"Must all changes be atomic and visible together?"} -->|yes| B{"Same database or one transaction-capable system?"}
B -->|yes| C["Local ACID transaction"]
B -->|no| D{"Can the services be merged?"}
D -->|yes| C
D -->|no| E{"Can resources be held with an expiry?"}
E -->|yes| F["TCC or saga with holds"]
E -->|no| G["2PC among a few controlled stores"]
A -->|no| H["Saga with compensations"]
Read it from the top: most business workflows end up on the right-hand branches, because they involve external parties that cannot join a commit protocol.
TIP
Before reaching for any protocol, ask whether the data really belongs in separate services. If two services must always be updated atomically, they may be one service with the wrong boundary. Merging them is often the cheapest solution.
Sagas: a sequence of local transactions
A saga breaks a business transaction into steps. Each step is a local transaction in one service. Each step that changes state has a compensating action that semantically undoes it. If step N fails, the saga runs the compensations for steps N-1 down to 1.
For the order example:
Step
Local transaction
Compensation
1
Reserve stock
Release reservation
2
Charge payment
Refund payment
3
Confirm order
Cancel order
Compensation is semantic, not a database rollback. A charge that was captured cannot be un-captured; you issue a refund, which the customer sees as two line items. The email you already sent cannot be unsent; you send a correction. Design the business process with this in mind.
Sagas give up isolation. Other requests can see the intermediate state: stock reserved but payment pending. You manage that with techniques such as:
Semantic locks: mark records as PENDING so other flows know they are mid-saga.
Commutative updates: design operations so order does not matter.
Re-reading values before acting, and versioning records to detect concurrent changes.
Reordering steps so the hardest-to-undo action comes last, and often placing a pivot step (the go/no-go point) after the compensatable steps, so that every step after it only needs to be retried until it succeeds.
Orchestration vs choreography
There are two ways to coordinate a saga.
Style
How it works
Strengths
Weaknesses
Orchestration
A central component tells each service what to do and tracks state
Flow visible in one place; easier to reason about, monitor and change
Orchestrator must be reliable; risk of putting business logic in it
Choreography
Services react to each other's events; no central controller
Loose coupling; no single coordinator
Flow is implicit across services; hard to see, debug, and avoid cyclic dependencies
A rough rule: choreography suits short flows with two or three services. As flows grow, orchestration usually pays for itself because someone can finally answer "where is order 4821 stuck?". Durable workflow engines persist the orchestrator's state and resume it after crashes. Replacing Cron and Spring Batch with Temporal shows one such engine handling retries and long-running state.
The transactional outbox pattern
Sagas rely on messages: "step done", "please charge". There is a trap. A service typically must do two things: update its database and publish a message. If it commits and then crashes before publishing, the next step never happens. If it publishes first and then fails to commit, other services act on something that never happened. This is the dual-write problem: two separate systems cannot be updated atomically by one process.
The transactional outbox removes it. In the same local database transaction as the business change, insert a row into an outbox table describing the message. A separate relay then reads the outbox and publishes to the broker, marking rows as sent.
sequenceDiagram
participant S as Order service
participant DB as Order database
participant R as Outbox relay
participant B as Message broker
S->>DB: Begin transaction
S->>DB: Insert order row
S->>DB: Insert outbox row
S->>DB: Commit
R->>DB: Read unsent outbox rows
R->>B: Publish message
B->>R: Ack
R->>DB: Mark row sent
Now both facts are committed or neither is. The relay may crash after publishing but before marking the row sent, so the message can be published more than once. That is acceptable only because consumers handle duplicates, which is the next section. The relay can be a polling process, or a change-data-capture tool reading the database log. Polling is simpler; log tailing reduces latency and load but adds infrastructure.
Order is only preserved if you keep it: publish outbox rows in insertion order per entity key, or include a version number so consumers can detect gaps.
Idempotency and "exactly-once effects"
Networks and brokers deliver messages at least once when reliability matters, so duplicates will happen. True exactly-once delivery across independent systems is not something you can assume. What you can build is exactly-once effects: the message may arrive several times, but its effect happens once. The recipe is at-least-once delivery plus idempotent processing, meaning repeating an operation has the same result as doing it once.
Practical techniques:
Idempotency keys. The caller generates a unique key per logical operation (for example, a saga id plus step name). The receiver stores the key with the result in the same transaction as the business change. A repeated key returns the stored result without redoing the work.
Inbox table / processed-message table. Consumers record message ids they have handled and skip duplicates, again in the same local transaction as their side effect.
Naturally idempotent operations. "Set status to SHIPPED" is safe to repeat; "add 10 to balance" is not, unless guarded by a version check or key.
Pass keys to external providers. Many payment APIs accept an idempotency key so a retried charge is not applied twice. Check your provider's documentation.
-- Same transaction as the business changeINSERT INTO processed_messages (message_id) VALUES ($1);
-- A unique constraint on message_id makes a duplicate fail the whole transactionUPDATE accounts SET balance = balance - $2WHERE id = $3;
If a duplicate arrives, the unique constraint violation aborts the transaction, so the debit is not applied twice. The consumer treats that specific error as "already processed" and acknowledges the message.
TCC is a pattern that sits between 2PC and sagas, and is common in payment and booking domains where resources can be held.
Try: reserve the resource tentatively (hold funds, hold a seat) without finalising.
Confirm: if all participants tried successfully, finalise each.
Cancel: if any try failed, release the holds.
The reservations are first-class business concepts, so other users see "held" rather than a locked database row. Reservations normally carry an expiry so a crashed coordinator cannot hold inventory forever. Confirm and cancel must be idempotent, and a cancel that arrives before its try (due to reordering or timeouts) must be handled by recording it so the late try is rejected.
TCC gives stronger isolation than a plain saga, but each participant must implement three operations and a reservation model, which is real design work.
Failure scenarios to walk through
A good way to test a design is to take each step and ask what happens if it fails or repeats. For the order saga with an orchestrator:
Failure
What state results
Recovery
Inventory reserve fails (out of stock)
Nothing to undo
Orchestrator marks order rejected
Payment times out
Unknown whether charged
Retry with same idempotency key; if still unknown, query status, then proceed or refund
Payment declines
Stock reserved
Run compensation: release reservation
Orchestrator crashes mid-saga
Saga state persisted
Restart, reload state, continue or compensate
Compensation itself fails
Partially undone
Retry compensation indefinitely with backoff; alert a human after a threshold
Duplicate "stock reserved" event
Same event twice
Inbox table ignores the second
Outbox relay crashes after publish
Row still marked unsent
Message republished; consumers deduplicate
Two rules fall out of the table:
Compensations must be retried until they succeed, so they must be idempotent and as simple as possible. A compensation that can fail permanently needs a manual-repair queue.
Every saga needs a timeout and an owner. A saga stuck forever in PENDING is a data-quality incident waiting to be noticed. Track age, and alert.
WARNING
Do not retry a non-idempotent step blindly. If "charge card" has no idempotency key, a retry after an ambiguous timeout can charge twice. Add the key first, then add the retry.
In practice: a flight, hotel and payment booking saga
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
An online travel agency sells flight-plus-hotel bundles. Eighteen engineers run Spring Boot services on Kubernetes, each with its own PostgreSQL database, with Kafka for events. Flights and hotels come from third-party supplier APIs, and card payments go through a payment gateway. The suppliers do not join any commit protocol, flight fares and hotel rooms can each be held for about 15 minutes, and payment supports authorise now and capture later. Sales peaks reach roughly 40 bookings per second (assumption).
Today a single Booking API request calls the flight supplier, the hotel supplier and the gateway in sequence, with try/catch blocks that attempt to undo earlier steps. The numbers that hurt (all illustrative): about 2 percent of bookings end inconsistent (paid but not ticketed, or ticketed but not paid), support needs around 25 minutes to fix each, and a supplier timeout leaves nobody certain what happened.
Starting architecture
There is no durable record of progress. If the pod dies after step 2, nothing knows steps 1 and 2 happened.
Options considered
Option
What it fixes
What it costs
Risk
A. Smaller change: add retries and a nightly reconciliation script
Some stuck bookings, found a day later
A few days
Customers still wait overnight; double-charge risk without idempotency keys
B. Orchestrated saga on a durable workflow engine, with outbox and idempotency keys
Lost progress, ambiguous outcomes, manual repair
A new engine to operate; a quarter of work
Team must learn workflow determinism and versioning
C. Choreographed saga over Kafka events
Loose coupling
Moderate
A six-step flow with compensations becomes hard to see and debug
The decision
Field
Entry
Context
Irreversible supplier steps, ambiguous timeouts, and a need to answer "where is booking 4821 stuck?" in seconds.
Decision
Adopt B. Order the steps: hold flight, hold hotel, authorise payment, ticket flight (the pivot), confirm hotel, capture payment. Everything before the pivot has a compensation (release, release, void). Everything after is retried until it succeeds.
Consequences
Good: durable progress, one place to see state, automatic recovery. Bad: eventual consistency visible to users ("booking in progress"), and an engine to run.
Revisit when
Saga steps exceed about ten, or one supplier's failure rate keeps compensation volume above a few percent.
The pivot is ticketing. States above it can compensate; states below it only move forward. Roll out behind a feature flag: first internal bookings against supplier sandboxes, then 1, 5 and 25 percent of traffic, with the old flow as the instant fallback. Keep the 2 percent inconsistency rate as the comparison baseline. Dashboards and alerts: saga age percentiles, compensation rate per supplier, depth of the manual-repair queue, duplicate-key hits (proof idempotency is working), and the gap between captured payments and ticketed flights, which should be zero.
The 2 a.m. failure
The page reads: "Saga compensation backlog above 50 for 10 minutes". The hotel supplier is returning 5xx errors, so release calls for cancelled bookings keep failing.
sequenceDiagram
participant O as Orchestrator
participant H as Hotel supplier
participant P as Payment gateway
O->>P: Void authorisation
P->>O: Voided
O->>H: Release hold with idempotency key
H--xO: 503 error
Note over O: Retry with backoff, same key
O->>H: Release hold again
H--xO: 503 error
Note over O: Hold expires on its own after 15 minutes
Detection comes from the saga_compensation_pending gauge and a retry-count alert. First three checks: filter failed activities by type ReleaseHotelHold in the workflow UI, check the supplier's status and the circuit breaker state, and confirm the retries reuse the same idempotency key. Mitigation: customers are safe, because payment is already voided and the hold expires by itself. Pause new bundles that use that supplier, and let retries continue. Follow-up fix: a per-supplier circuit breaker, a manual-repair queue for holds that outlive expiry, and a post-incident check that no retry ever used a fresh key.
Architect's notes
Order steps by reversibility. Put the hardest-to-undo action last among the steps that might fail, and write down which one is the pivot.
Prefer holds with expiry. An expiring hold is a safety net that works even if your orchestrator is down.
Ask in the review: "What does the customer see at each state, and what does support see?" Intermediate states are a product decision, not just a technical one.
Cost trap. Workflow history grows with every step and retry. Plan retention and payload size early.
Ownership. Name one team that owns the orchestrator and its stuck-saga queue. Service teams own their own compensations.
Defer on purpose. Do not add cross-supplier optimisation or partial-refund logic until the basic saga is boring.
Common mistakes
Dual writes to a database and a broker. A crash between the two leaves them disagreeing. Use the transactional outbox or change data capture.
Assuming exactly-once delivery. Duplicates can and will arrive. Make every consumer idempotent and store processed ids with the side effect.
Treating compensation as rollback. Real-world actions cannot be undone, only offset. Design visible, business-meaningful compensations and order steps so the irreversible one comes last.
Using 2PC across unreliable or external systems. Third-party APIs and many brokers cannot participate, and the blocking risk spreads. Prefer sagas or a redesigned boundary.
Ignoring intermediate states. Other requests see half-finished sagas. Use status fields and semantic locks, and show users honest states like "processing".
No timeouts or monitoring on sagas. Stuck workflows silently hold stock and money. Persist saga state, set deadlines, and alert on age.
Hiding business logic in a choreographed event web. Nobody can see the flow. Switch to orchestration when the number of steps or services grows.
Key takeaways
Atomicity across services is a protocol, not a feature. A single database transaction cannot cover several service-owned databases.
A timeout means "unknown". Designs must handle the ambiguous outcome, not just success and failure.
Two-phase commit blocks. Participants hold locks after voting yes and wait for the coordinator, so it hurts availability and throughput.
Sagas trade isolation for availability. Local transactions plus compensations give eventual consistency, with visible intermediate states.
Choose orchestration as flows grow. Choreography is loose but implicit; orchestration is visible and easier to operate.
The outbox solves dual writes. Write the message with the data in one local transaction, then relay it, accepting at-least-once publication.
Aim for exactly-once effects. At-least-once delivery plus idempotent processing, using keys and processed-message tables.
Redraw boundaries first. If two services must always change together, consider merging them before building a protocol.
Practice exercises
Exercise 1 — Starter (30 min): Write the saga table (step, local transaction, compensation) for a trip booking that reserves a flight, a hotel and a car. Mark which step should be the pivot (the point of no return) and justify the order.
Exercise 2 — Intermediate (2 hours): Draw a sequence diagram of the order saga using an orchestrator and the outbox pattern. Then trace three failures on it: the payment service crashes after charging but before replying; the relay publishes a message twice; the orchestrator restarts mid-saga. For each, state what the database contains afterwards and which mechanism restores consistency.
Exercise 3 — Advanced (half day): Design a money-transfer feature between accounts held in two different services. Compare a 2PC design, a TCC design with holds that expire, and a saga with compensations. For each, specify the failure behaviour during a 10-minute outage of one service, what customers can see, the idempotency scheme, and the monitoring you would add. Recommend one and write the trade-off in three sentences.
Related reading
Message Queues - delivery semantics, acknowledgements and idempotent consumers that sagas depend on.
CQRS and Event Sourcing - an alternative way to model state changes as a reliable stream of events.
Architecture Styles - why service boundaries decide how many distributed transactions you need.
Enroll to keep reading
Lesson 2 of 14 · Advanced System Design and Case Studies
Learn why transactions across services are hard, how two-phase commit blocks, and how sagas, the outbox pattern and idempotency deliver reliable outcomes.
In the rest of this lesson
Sagas: a sequence of local transactions
The transactional outbox pattern
Idempotency and "exactly-once effects"
Try-Confirm/Cancel (TCC)
Failure scenarios to walk through
In practice: a flight, hotel and payment booking saga
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.