Skip to content

    System Design

    System Design: Distributed Transactions and Sagas

    Learn why transactions across services are hard, how two-phase commit blocks, and how sagas, the outbox pattern and idempotency deliver reliable outcomes.

    By Published 16 min read

    Share

    This lesson is part of Advanced System Design and Case Studies. Enroll for free, then sign in, to read it in full.

    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:

    1. 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.
    2. 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:

    1. Reserve stock.
    2. Charge the card.
    3. 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.

    Saga orchestration: one orchestrator calls each service in turnSaga orchestrator to Inventory (1 reserve); Saga orchestrator to Payment (2 charge); Saga orchestrator to Order (3 confirm)ORCHESTRATIONSaga orchestrator: tracks the stateSagaorchestratortracks the stateInventoryInventoryPaymentPaymentOrderOrder1 reserve2 charge3 confirm
    Saga choreography: services react to each other's eventsOrder service to Inventory service (OrderPlaced); Inventory service to Payment service (StockReserved); Payment service to Order service (PaymentTaken)CHOREOGRAPHYOrder serviceOrder serviceInventory serviceInventoryservicePayment servicePaymentserviceOrderPlacedStockReservedPaymentTaken
    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 change
    INSERT INTO processed_messages (message_id) VALUES ($1);
    -- A unique constraint on message_id makes a duplicate fail the whole transaction
    UPDATE accounts SET balance = balance - $2 WHERE 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.

    See Message Queues for delivery semantics in detail.

    Try-Confirm/Cancel (TCC)

    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

    Starting architecture: Booking API calling three suppliers in sequenceWeb and mobile to Booking API; Booking API to Flight supplier (1 sync call); Booking API to Hotel supplier (2 sync call); Booking API to Payment gateway (3 sync call); Booking API to Booking databaseWeb and mobileWeb and mobileBooking API: calls in sequenceBooking APIcalls in sequenceFlight supplierFlightsupplierHotel supplierHotel supplierPayment gatewayPaymentgatewayBooking databaseBookingdatabase1 sync call2 sync call3 sync call

    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.

    Rollout and verification

    stateDiagram-v2 [*] --> FlightHeld FlightHeld --> HotelHeld: hold hotel HotelHeld --> PaymentAuthorised: authorise PaymentAuthorised --> Ticketed: ticket flight, pivot Ticketed --> HotelConfirmed: confirm hotel HotelConfirmed --> Completed: capture payment FlightHeld --> Compensating: step failed HotelHeld --> Compensating: step failed PaymentAuthorised --> Compensating: ticketing failed Compensating --> Cancelled: holds released and payment voided Completed --> [*] Cancelled --> [*]

    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.

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments