Skip to content
SparkTime, home
System Design From Zero to Interview-Ready
Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
6 · Distributed Systems
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
11 · Large-Scale Case Studies
12 · Interview Readiness
Lesson 21 of 45

Lesson 21 · Distributed Systems

Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas

How to keep data consistent when one business action spans a database and a message broker, or several services. Covers the dual-write problem, the transactional outbox and change data capture, two-phase commit and why it blocks, and sagas with compensating transactions, choreography versus orchestration, pivot steps and isolation anomalies, with a runnable saga.

On this page

Key takeaways

  • A local ACID transaction cannot span a database and a broker, or two services; writing to both separately (a dual write) loses or invents events when a crash falls between them.
  • The transactional outbox writes the business change and the event in one local transaction, and a relay publishes the event later, at least once.
  • Two-phase commit gives atomicity across participants but blocks while the coordinator is unavailable and couples everyone's availability; it is rare between microservices.
  • A saga splits a business transaction into local transactions with compensating actions, coordinated by events (choreography) or by an orchestrator.
  • Compensation is not rollback: order steps so irreversible ones come last, make every step idempotent, and design for the anomalies that sagas allow.

Placing an order looks like one action to a customer. Inside an e-commerce system it is several: create the order, reserve stock, charge the card, schedule shipping, and tell other parts of the system that it happened. In a single database, a transaction would make all of that atomic. But the order lives in the order service's database, stock in the inventory service, payments with a provider, and events in a message broker. There is no single transaction that spans them.

What happens when the card is charged and then the shipping step fails? Or the order is saved and the "order placed" event is never published? This article covers the three main tools for these situations: the transactional outbox for "database plus event", two-phase commit for true cross-system atomicity (and why it is rarely used between services), and sagas for long business processes across services.

What you will learn

#
  • Why local transactions stop at the edge of one database.
  • The dual-write problem, step by step.
  • The transactional outbox pattern, relays, change data capture and the inbox on the consumer side.
  • Two-phase commit: how it works and why it blocks.
  • Sagas: compensating transactions, choreography versus orchestration, pivot and retryable steps.
  • The anomalies sagas allow and the countermeasures.
  • How to choose between these approaches, with a runnable saga example.

The edge of a transaction

#

A database transaction (article 14) is atomic because one system controls everything it touches: its own log, its own locks, its own commit. The moment an operation involves two systems that commit independently, such as your database and a message broker, or your database and another service's database, no single commit covers both.

This shows up in two shapes:

  1. Database plus event: save data and publish a message about it.
  2. Several services: a business process that changes data owned by several services.

The dual-write problem

#

Pixly's upload API (article 05) saves a photo row and publishes photo.uploaded. Written naively:

Code
1. INSERT photo row into the database   (commit)2. Publish "photo.uploaded" to the broker

What if the process crashes between 1 and 2? The photo exists, but no event is ever published: no thumbnails, no feed update, forever. Swap the order:

Code
1. Publish "photo.uploaded"2. INSERT photo row  (crash or failure here)

Now consumers receive an event about a photo that does not exist. Wrapping both in a database transaction does not help, because the broker is not part of it: the publish happens immediately, while the database commit can still fail.

Every variation has a gap. This is the dual-write problem: writing to two systems without a shared transaction means a crash can leave them disagreeing. It is subtle because it only fails occasionally, under crashes or timeouts, which is exactly when nobody is watching.

The transactional outbox

#

The fix is to write to one system atomically, and move the second write out of the request:

  1. In one local transaction, write the business change and an outbox row describing the event, in the same database.
  2. A separate relay reads unsent outbox rows, publishes them to the broker, and marks them sent.

The microservices.io description of the Transactional Outbox pattern presents exactly this: the service inserts messages into an outbox table as part of the local transaction, and a message relay publishes them (Transactional outbox). Microsoft's guidance describes the same pattern for Azure services (Transactional Outbox pattern with Azure Cosmos DB).

SQL
CREATE TABLE outbox (    event_id      UUID PRIMARY KEY,    aggregate_id  BIGINT NOT NULL,          -- for example the photo id; used for ordering    event_type    VARCHAR(100) NOT NULL,    payload       JSONB NOT NULL,    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),    published_at  TIMESTAMPTZ);BEGIN;INSERT INTO photos (photo_id, owner_id, status, ...) VALUES (9001, 42, 'processing', ...);INSERT INTO outbox (event_id, aggregate_id, event_type, payload)VALUES ('7b2f9c...', 9001, 'photo.uploaded', '{"photo_id": 9001, "owner_id": 42}');COMMIT;
Code
REQUEST PATH                         ASYNC PATHAPI --> one transaction:             Relay --reads unsent outbox rows--> Broker --> consumers        photos row + outbox row             --marks them published-->

Either both rows commit or neither does. If the relay crashes after publishing but before marking a row sent, it publishes the row again on restart. So outbox publication is at-least-once, and consumers must be idempotent (article 20), using event_id to deduplicate.

Relays: polling or change data capture

#
  • Polling relay: a job queries WHERE published_at IS NULL ORDER BY created_at every short interval, publishes, and updates. Simple; adds some database load and latency.
  • Change data capture (CDC): a connector reads the database's own change log (in PostgreSQL, the write-ahead log) and turns inserted outbox rows into messages. Lower latency and no polling queries; another component to operate. Tools such as Debezium are commonly used for this.

Ordering and cleanup

#
  • To keep events for one entity in order, publish them in created_at order per aggregate_id, and use the aggregate id as the message key (so a partitioned broker keeps them in one partition, article 11).
  • Delete or archive published outbox rows periodically so the table stays small.

The inbox, on the consumer side

#

The receiving service often mirrors the pattern: record the incoming event_id in an inbox (processed messages) table in the same transaction as the effect, and skip duplicates. This is the idempotent consumer from article 20 under another name; Microsoft describes it as the Idempotent Consumer pattern (Idempotent Consumer pattern). Outbox on the sender plus inbox on the receiver gives effectively-once processing across services.

Two-phase commit (2PC)

#

What if you need several independent databases to commit atomically: all or none, even across machines? The classic protocol is two-phase commit, run by a coordinator:

Code
Phase 1 (prepare / vote):  Coordinator --"prepare"--> Participant A: writes changes durably, holds locks, votes YES  Coordinator --"prepare"--> Participant B: votes YES   (any NO -> abort everyone)Phase 2 (commit):  Coordinator records "commit" durably, then  Coordinator --"commit"--> A and B: make changes permanent, release locks

Once a participant votes yes, it has promised to commit if told to, so it must keep its locks and wait for the coordinator's decision.

The blocking problem. If the coordinator crashes after the participants voted yes but before they learn the decision, they are stuck: they cannot commit (maybe the decision was abort) and cannot abort (maybe it was commit). They hold their locks until the coordinator recovers. Other transactions touching those rows wait too.

Costs of 2PC:

  • Latency: at least two round trips, plus durable writes, for every transaction.
  • Availability: the transaction needs every participant and the coordinator; it is only as available as all of them together (article 02's availability in series).
  • Coupling: every participant must support the protocol (for example the XA standard) and trust one coordinator.
  • Lock holding during the protocol reduces throughput under contention.

Where 2PC is used: inside distributed databases (which pair it with consensus-replicated participants so a coordinator failure does not block indefinitely), and occasionally between a few systems within one organisation at modest volume. Between independently deployed microservices, owned by different teams and using different stores, it is rarely a good fit, which is why sagas exist.

Sagas

#

A saga breaks a business transaction into a sequence of local transactions, one per service. Each step commits on its own and triggers the next. If a step fails, the saga runs compensating transactions to undo the effects of the steps that already completed. Microsoft's Saga pattern guidance describes exactly this structure, along with its two coordination styles and the risks it introduces (Saga design pattern), as does the microservices.io description (Saga).

Code
Order saga:  1. Order service:     create order (PENDING)          compensate: mark CANCELLED  2. Inventory service: reserve stock                   compensate: release stock  3. Payment service:   charge card                     compensate: refund  4. Order service:     mark CONFIRMED  5. Shipping service:  schedule delivery               (retry until it succeeds)

Compensation is not rollback

#

A compensating transaction is a new action that semantically undoes an earlier one, and it is visible: a refund appears on the customer's statement next to the charge; a cancelled order exists in history. Some actions cannot be compensated at all (an email cannot be unsent, goods cannot be un-shipped). This leads to an important design rule from the Saga pattern guidance:

  • Compensable steps come first: they can be undone.
  • The pivot step is the point of no return. Once it succeeds, the saga must complete.
  • Retryable steps come after the pivot. They must eventually succeed, so they are idempotent and retried until done.

Order the steps so that irreversible actions come last. Charge the card (refundable) before sending "your order is confirmed"; schedule shipping only after payment succeeds.

Choreography versus orchestration

#

Choreography: each service listens for events and decides what to do next. No central controller.

Code
Order svc --OrderCreated--> Inventory svc --StockReserved--> Payment svc --PaymentCaptured--> Order svc                                     \--StockFailed--> Order svc (cancel)

Orchestration: an orchestrator (a service or workflow engine) holds the saga's state and tells each participant what to do.

Code
                 Orchestrator (saga state stored durably)       /---- reserve ---->  Inventory      /<---- reserved -----     /------ charge ----->  Payment    /<------ failed ------   \------- release ----->  Inventory   (compensation)

Choreography

Orchestration

Coordination

Events between services

A central orchestrator

Good for

Short, simple flows with few services

Longer flows, many steps, complex compensation

Visibility

Flow is spread across services; harder to see and test

The flow is in one place; easier to monitor

Coupling

Services must know which events to react to; risk of cyclic dependencies

Participants are simpler; the orchestrator is an extra component and a potential point of failure

These benefits and drawbacks follow Microsoft's comparison in the Saga pattern guidance. A practical rule: choreography for two or three steps; orchestration beyond that, often using a durable workflow engine (products such as Temporal, AWS Step Functions or Azure Durable Functions are examples) so that saga state survives crashes and timeouts are handled for you.

A runnable saga orchestrator

#

This Java program models an orchestrator: it runs steps in order, and when one fails, runs the compensations of the completed steps in reverse order. Each step stands for a local transaction in a different service. The first run declines the card; the second succeeds.

Java
// File: SagaDemo.javaimport java.util.ArrayDeque;import java.util.Deque;import java.util.List;public class SagaDemo {    // One saga step: a local transaction in some service, and the action that undoes its effect.    record Step(String name, Runnable action, Runnable compensation) {}    static boolean cardDeclined = true;    static void runSaga(List<Step> steps) {        Deque<Step> completed = new ArrayDeque<>();        for (Step step : steps) {            try {                step.action().run();                completed.push(step);            } catch (RuntimeException e) {                System.out.println("  FAILED: " + step.name() + " (" + e.getMessage() + ")");                while (!completed.isEmpty()) {                    completed.pop().compensation().run();   // undo completed steps, newest first                }                System.out.println("  saga finished: order cancelled");                return;            }        }        System.out.println("  saga finished: order confirmed");    }    static void chargeCard() {        if (cardDeclined) {            throw new IllegalStateException("card declined");        }        System.out.println("  payment captured");    }    public static void main(String[] args) {        List<Step> steps = List.of(                new Step("create order",                        () -> System.out.println("  order 77 created as PENDING"),                        () -> System.out.println("  compensate: order 77 marked CANCELLED")),                new Step("reserve stock",                        () -> System.out.println("  2 units reserved"),                        () -> System.out.println("  compensate: 2 units released")),                new Step("charge payment",                        SagaDemo::chargeCard,                        () -> System.out.println("  compensate: payment refunded")),                new Step("confirm order",                        () -> System.out.println("  order 77 CONFIRMED"),                        () -> { }));        System.out.println("Run 1, card declined:");        runSaga(steps);        cardDeclined = false;        System.out.println("Run 2, card accepted:");        runSaga(steps);    }}
Code
Run 1, card declined:  order 77 created as PENDING  2 units reserved  FAILED: charge payment (card declined)  compensate: 2 units released  compensate: order 77 marked CANCELLED  saga finished: order cancelledRun 2, card accepted:  order 77 created as PENDING  2 units reserved  payment captured  order 77 CONFIRMED  saga finished: order confirmed

A production orchestrator adds what this model leaves out: it persists saga state after every step (so a crash resumes instead of restarting), it retries steps and compensations with backoff, every step and compensation is idempotent (because retries repeat them), and steps have timeouts so a silent participant does not hang the saga forever.

What if a compensation fails?

#

A refund call can fail too. Compensations must be retried until they succeed (they are retryable steps), with alerts if they keep failing, and a manual process as the last resort. This is one reason to keep sagas short and compensations simple.

Sagas are not isolated

#

Because each step commits independently, other transactions can see a saga's intermediate states. Microsoft's guidance lists the typical anomalies: lost updates (one saga overwrites another's changes), dirty reads (reading changes another saga will later compensate) and fuzzy or non-repeatable reads (steps of one saga reading inconsistent data). It also lists countermeasures (Saga design pattern):

Countermeasure

Idea

Example

Semantic lock

Mark records as "in progress" at the application level

Order status PENDING tells other processes not to ship it yet

Commutative updates

Design updates that give the same result in any order

Stock increments and decrements, rather than "set stock to 5"

Pessimistic view

Reorder steps to reduce exposure to dirty reads

Reserve stock before showing "confirmed" anywhere

Reread values

Check data has not changed before acting

Re-check price before charging

Version files

Record the operations on a record and apply them in order

An append-only log of changes per order

In practice, the most important habit is the semantic lock: model intermediate states (PENDING, RESERVED, PAYMENT_PENDING) explicitly, and make every other process respect them.

Choosing an approach

#

Situation

Approach

All data that must change together is in one database

A local transaction. Prefer redesigning boundaries to make this true.

Save data and publish an event about it

Transactional outbox (plus idempotent consumers)

A business process across several services, where temporary inconsistency is acceptable and compensation is possible

Saga (choreography for short flows, orchestration for longer ones)

Several databases must commit atomically, low volume, systems you control, participants support it

Two-phase commit may be acceptable; a distributed database that handles it internally is often better

You find yourself needing distributed transactions everywhere

Your service boundaries probably split data that belongs together (article 26)

Worked example: checkout across services

#

Services: Order, Inventory, Payment (calls an external provider), Shipping, Notification.

Design:

  1. POST /v1/orders (with an idempotency key) runs one local transaction in the Order service: insert the order as PENDING and an outbox row OrderCreated. Respond 202 Accepted with the order id; the client polls or receives a push when the status changes.
  2. An orchestrator (or the Order service acting as one) consumes OrderCreated and drives the saga, storing its state in a checkout_sagas table.
  3. Reserve stock: Inventory reserves with a conditional update, recording the reservation keyed by order id (idempotent). On "insufficient stock", the saga cancels the order.
  4. Charge: Payment creates a payment record and calls the provider with a provider idempotency key derived from the order (article 20). This is the pivot. If declined: release stock, cancel order.
  5. After the pivot: confirm the order, schedule shipping, notify the customer. These are retryable; they are retried until they succeed. Notification is last because an email cannot be taken back.
  6. Timeouts: if payment stays pending past a deadline (for example the provider is slow), the orchestrator queries the provider for the final status rather than guessing.

Consistency the user sees: the order shows PENDING for a moment, then CONFIRMED or CANCELLED. Stock may appear reserved for a few seconds before a declined payment releases it. Both are acceptable, and both are visible states in the UI rather than hidden glitches.

Failure modes

#

Failure

Effect

Mitigation

Crash between database write and publish (dual write)

Lost or phantom events

Transactional outbox

Relay publishes twice

Duplicate events

Idempotent consumers (inbox)

Orchestrator crashes mid-saga

Saga stuck half-way

Persist saga state; resume on restart; durable workflow engines

A participant never replies

Saga hangs

Per-step timeouts, status queries, compensation

Compensation fails

Inconsistent state remains

Retry compensations; alert; manual runbook

Concurrent sagas on the same data

Lost updates, dirty reads

Semantic locks, commutative updates, rereads

2PC coordinator fails after prepare

Participants block holding locks

Avoid 2PC across services; use systems that replicate the coordinator's decision

Trade-offs

#

Choice

You gain

You pay

Outbox

No lost or phantom events

A table, a relay, at-least-once delivery, slight delay

2PC

True atomicity across participants

Blocking, latency, availability coupling, protocol support

Saga

Each service stays independent and available

Temporary inconsistency, compensation logic, anomalies to manage

Orchestration

Visible, testable flow

A central component to build and run

Choreography

No central component

Flow hard to see; risk of event spaghetti

Common mistakes

#
  • Publishing events right after a database commit in application code and calling it reliable.
  • Treating compensation as a database rollback, and putting irreversible steps first.
  • Non-idempotent saga steps or compensations, so retries double effects.
  • No timeouts on saga steps.
  • Hiding intermediate states from users and from other processes.
  • Splitting services so finely that every operation needs a saga.

In an interview

#
  • When you write to a database and publish an event, say "transactional outbox", and that consumers are idempotent.
  • When a process spans services, propose a saga, list the steps with compensations, and point out the pivot.
  • Say whether it is orchestrated or choreographed, and why.
  • If asked about 2PC, explain the blocking problem and why you avoid it across services.

Exercises

#

Exercise 1

Find the bug

#
Code
@Transactionalvoid placeOrder(order) {    orderRepository.save(order);    broker.publish("OrderPlaced", order);    // inside the database transaction    inventoryClient.reserve(order.items);    // HTTP call to another service}

List everything that can go wrong and redesign it.

Exercise 2: Design an outbox

#

For a banking app that records transfers and publishes TransferCompleted events consumed by a notification service and a fraud service, design the outbox table, the relay, and what each consumer does to stay correct. Events for one account must be processed in order.

Exercise 3: Travel booking saga

#

A travel site books a flight, a hotel and a rental car for a trip, each with a different external provider. Design the saga: order the steps, give each compensation, identify the pivot, and say what happens if the car booking fails after the flight is ticketed.

Exercise 4: Choreography or orchestration?

#

Choose and justify:

  1. When a user signs up, send a welcome email and create a default workspace.
  2. A loan application: credit check, fraud check, document verification, underwriting decision, account creation, disbursement, with manual review possible at two points.
Show solutionHide solution

Problems:

  • Dual write: publish happens immediately, but the database transaction may still roll back (for example if reserve throws), so consumers receive OrderPlaced for an order that does not exist.
  • If the publish succeeds and the commit later fails, or the process crashes after commit but the broker had already received the message, behaviour depends on timing; there is no guarantee either way.
  • A remote call inside a database transaction: the transaction (and its locks) stays open during an HTTP call to Inventory, which may be slow or time out.
  • No atomicity with inventory: if reserve succeeds and then the commit fails, stock is reserved for an order that does not exist (and nothing releases it). If reserve times out, you do not know whether stock was reserved.

Redesign: in one local transaction, save the order as PENDING and an outbox row OrderCreated. Commit. A relay publishes the event. A saga reserves stock with an idempotent reservation keyed by order id, then continues to payment, with compensation (release) on failure and timeouts with status checks. Consumers of OrderCreated deduplicate by event id.

Exercise 2

Show solutionHide solution
SQL
CREATE TABLE outbox (    event_id     UUID PRIMARY KEY,    account_id   BIGINT NOT NULL,          -- ordering key    event_type   VARCHAR(50) NOT NULL,    payload      JSONB NOT NULL,    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),    published_at TIMESTAMPTZ);CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
  • The transfer and its outbox row (one per affected account, or one with both ids) are written in one transaction.
  • Relay: CDC on the outbox table (or polling in created_at order), publishing with account_id as the message key to a partitioned topic, so each account's events stay in order. Marks rows published; tolerates republishing after crashes.
  • Notification consumer: inbox table keyed by event_id to avoid duplicate messages; sending is asynchronous; a duplicate SMS for a transfer is a nuisance, so deduplicate carefully.
  • Fraud consumer: inbox keyed by event_id, plus per-account ordering from the partition. If it maintains running totals, it updates them in the same transaction as the inbox insert. Any event older than the last applied sequence for that account is ignored.
  • Published rows are deleted after a retention period (for example 7 days).

Exercise 3

Show solutionHide solution

Step order, most reversible first (assuming hotel and car allow free cancellation within a window, and flights are the hardest to undo):

  1. Reserve hotel (hold, cancellable). Compensation: cancel the hold.
  2. Reserve car (cancellable). Compensation: cancel.
  3. Ticket the flight. This is the pivot: once ticketed, cancellation may cost money or be impossible.
  4. Confirm hotel and car (convert holds to bookings) and email the itinerary: retryable steps after the pivot.

If the car fails before the pivot (step 2), compensate by cancelling the hotel hold and report "car unavailable; would you like to continue without a car?" If a car problem arises after the flight is ticketed (for example converting the hold fails), do not unwind the flight automatically: retry, try an alternative car provider, and if that fails, ask the customer, offering a refund for the car portion. Business rules, not technology, decide here, and the design should make the customer's choice explicit. Each provider call uses an idempotency key derived from the trip id.

Exercise 4

Show solutionHide solution
  1. Choreography. Two independent reactions to one UserSignedUp event; no ordering or compensation between them. An orchestrator would be unnecessary machinery.
  2. Orchestration, ideally with a durable workflow engine. Many steps, strict ordering, branches, human review that may take days, timeouts and compensations (for example cancelling the account if disbursement fails). Keeping this flow in one visible place makes it testable and auditable, which matters for a regulated process.

Where to go next

#

Sources & further reading

  1. microservices.io
  2. microservices.io
  3. learn.microsoft.com
  4. learn.microsoft.com
  5. learn.microsoft.com
  • Guide · Technology

    Start Here: Your Roadmap from Zero to Job-Ready DSA

  • Guide · Technology

    SQL and PostgreSQL Fundamentals for Java Developers

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages