Designing Distributed Transactions with Sagas and Two-Phase Commit

By Oleksandr Andrushchenko — Published on — Modified on

Designing Distributed Transactions with Sagas and Two-Phase Commit
Designing Distributed Transactions with Sagas and Two-Phase Commit

Distributed transactions coordinate one business operation across multiple services, databases, queues, or external systems. Unlike a local database transaction, no single transaction manager automatically guarantees that every participating component commits or rolls back together.

Sagas and Two-Phase Commit solve this problem using different consistency models. Sagas accept temporary inconsistency and recover through compensating actions, while Two-Phase Commit coordinates participants so they either commit or abort as one distributed unit.

Table of Contents

The Distributed Transaction Problem

A business operation often spans several independently deployed systems. Creating an order may require reserving inventory, authorizing payment, creating a shipment, and updating customer-facing status.

Create Order
  -> Order Service
  -> Inventory Service
  -> Payment Service
  -> Shipping Service
  -> Notification Service

Each participant can succeed or fail independently. The network can time out after an operation succeeds, a service can restart between steps, or a message can be delivered more than once.

Failure Result
Inventory succeeds, payment fails Inventory remains reserved without a completed order.
Payment succeeds, response times out Caller cannot tell whether retrying creates a duplicate charge.
Shipment succeeds, orchestrator crashes Workflow progress may be lost without durable state.
Event is delivered twice Side effects may be repeated without idempotency.

Key Point: distributed transaction design is primarily failure design. The normal success path is usually the simplest part.

Local vs Distributed Transactions

Local and distributed transactions operate across different consistency boundaries. A relational database can atomically commit changes under one transaction manager, while independently operated services cannot assume one shared commit operation.

Local Atomicity

A local database transaction groups several statements into one atomic unit.

BEGIN;

-- Reserve inventory inside the same database transaction.
UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE product_id = 'prd_123'
  AND available_quantity > 0;

-- Create the order only if the reservation succeeded.
INSERT INTO orders (
    id,
    product_id,
    status
)
VALUES (
    'ord_123',
    'prd_123',
    'created'
);

COMMIT;

If a statement fails, the database can restore the previous state because it controls all participating data.

Partial Failure

Distributed systems cannot assume every participant is available at the same moment. A timeout does not prove that an operation failed; it proves only that the result was not received in time.

Order Service
  -> Payment Service: authorize payment

Payment Service
  -> payment succeeds
  -> response is lost

Order Service
  -> sees timeout
  -> outcome is unknown

This uncertainty makes retries, idempotency, reconciliation, and persistent workflow state essential.

Consistency Boundaries

A system should keep strongly consistent invariants inside the smallest practical local transaction and coordinate broader workflows asynchronously.

Invariant Preferred Boundary
Account balance cannot become negative One database transaction
Order eventually reflects payment status Distributed workflow
Inventory reservation and outbox event are recorded together One local transaction
Email is eventually sent after order completion Asynchronous side effect

Rule of Thumb: use local ACID transactions inside a service and explicit coordination between services.

Saga Pattern

A Saga represents one distributed business transaction as a sequence of local transactions. Each participant commits independently, and failures are handled through retries or compensating actions.

Advantages

  • No long-lived distributed locks: services commit local work independently.
  • Good service autonomy: each service owns its database and transaction boundary.
  • Scales across heterogeneous systems: participants may use different databases or external APIs.
  • Works with asynchronous messaging: workflows can continue through temporary outages.
  • Supports long-running operations: a saga may take seconds, hours, or days.

Disadvantages

  • Temporary inconsistency: intermediate states are visible before completion.
  • Compensation complexity: every reversible side effect needs a business-level undo action.
  • Harder reasoning: failure paths multiply as the number of steps grows.
  • No automatic isolation: concurrent workflows may observe or modify intermediate state.
  • Operational state required: progress, attempts, errors, and compensation status must be durable.

When to Use / Real-World Use Cases

  • E-commerce checkout across orders, inventory, payments, and shipping.
  • Travel booking across flights, hotels, and rental cars.
  • Account provisioning across identity, billing, and infrastructure systems.
  • Marketplace transactions involving buyers, sellers, payouts, and fulfillment.
  • Long-running business workflows that cannot hold database locks.

Example

Forward steps:
  1. Create order
  2. Reserve inventory
  3. Authorize payment
  4. Create shipment

Compensations:
  4. Cancel shipment
  3. Void or refund payment
  2. Release inventory
  1. Mark order canceled

Compensation usually runs in reverse order because later actions may depend on earlier ones.

Saga Choreography

In a choreographed saga, services publish domain events and react to events from other services. No central component explicitly controls the entire workflow.

Advantages

  • Loose runtime coupling: participants react to events rather than call one central controller.
  • No orchestration service: fewer central workflow components.
  • Natural event-driven architecture: domain events drive state transitions.
  • Independent extensibility: new consumers can subscribe without changing the producer.

Disadvantages

  • Workflow becomes implicit: the end-to-end process is distributed across event handlers.
  • Difficult debugging: understanding one transaction requires tracing many services and topics.
  • Event cycles: poorly designed reactions can create loops or unexpected cascades.
  • Harder global policy: timeouts, retries, and compensation rules are spread across participants.
  • Testing complexity: end-to-end behavior depends on many independently deployed consumers.

When to Use / Real-World Use Cases

  • Small workflows with a limited number of participants.
  • Event-driven domains where services already communicate through domain events.
  • Independent reactions such as analytics, email, and search indexing.
  • Workflows without complex branching or centralized deadlines.

Example

Order Service
  -> publishes OrderCreated

Inventory Service
  -> consumes OrderCreated
  -> reserves inventory
  -> publishes InventoryReserved

Payment Service
  -> consumes InventoryReserved
  -> authorizes payment
  -> publishes PaymentAuthorized

Shipping Service
  -> consumes PaymentAuthorized
  -> creates shipment
  -> publishes ShipmentCreated

If payment fails, the Payment Service may publish PaymentAuthorizationFailed, which causes the Inventory Service to release its reservation and the Order Service to cancel the order.

Saga Orchestration

In an orchestrated saga, one component stores workflow state and commands each participant to perform or compensate a step.

Advantages

  • Explicit workflow: sequence, branches, timeouts, and compensations are visible in one place.
  • Central observability: the orchestrator can expose end-to-end saga status.
  • Easier complex logic: branching, parallel steps, deadlines, and retries are easier to model.
  • Controlled compensation: rollback order and policies are centrally defined.
  • Clear ownership: one component owns workflow progression.

Disadvantages

  • Central dependency: the orchestrator must be highly available and durable.
  • Potential business-logic concentration: too much domain knowledge may move into one service.
  • Command coupling: the orchestrator depends on participant command contracts.
  • State-machine complexity: every transition and failure path must be modeled.
  • Risk of a distributed monolith: one orchestrator may coordinate unrelated domains.

When to Use / Real-World Use Cases

  • Checkout workflows with sequential dependencies and compensations.
  • Long-running provisioning with deadlines and human approval steps.
  • Complex financial workflows requiring explicit audit history.
  • Processes with branching logic or parallel execution.
  • Systems requiring a clear operational dashboard for workflow status.

Example

Order Saga Orchestrator
  -> ReserveInventory command
  <- InventoryReserved event

  -> AuthorizePayment command
  <- PaymentAuthorized event

  -> CreateShipment command
  <- ShipmentCreated event

  -> MarkOrderCompleted command

When payment authorization fails, the orchestrator sends ReleaseInventory and then marks the order canceled.

Two-Phase Commit

Two-Phase Commit, commonly abbreviated as 2PC, coordinates several transactional participants through a prepare phase and a commit phase.

Advantages

  • Strong atomic outcome: all participants commit or all abort.
  • No business compensation required: uncommitted changes are rolled back transactionally.
  • Simple application semantics: the operation appears as one transaction.
  • Useful for tightly controlled infrastructure: participants share a compatible transaction protocol.

Disadvantages

  • Blocking behavior: prepared participants may hold locks while waiting for the final decision.
  • Coordinator dependency: failure after prepare can leave participants uncertain.
  • Reduced availability: all participants generally need to be reachable for progress.
  • Operational complexity: in-doubt prepared transactions require monitoring and recovery.
  • Limited interoperability: many queues, NoSQL stores, SaaS APIs, and cloud services do not participate.
  • Poor long-running fit: locks and reserved resources should not remain open for minutes or hours.

When to Use / Real-World Use Cases

  • Short transactions across compatible relational databases.
  • Legacy enterprise systems using XA-compatible resource managers.
  • Tightly controlled environments where participants and coordinator are operated together.
  • High-value invariants that require atomic commit and cannot tolerate compensation.
  • Database federation scenarios where lock duration remains very short.

Example

Phase 1: Prepare

Coordinator
  -> Database A: prepare
  -> Database B: prepare

Database A:
  records changes
  keeps locks
  votes YES

Database B:
  records changes
  keeps locks
  votes YES

Phase 2: Commit

Coordinator
  -> Database A: commit
  -> Database B: commit

If any participant votes no during prepare, the coordinator tells all participants to abort.

Saga vs Two-Phase Commit

Sagas and Two-Phase Commit optimize for different system properties. A Saga favors availability, service autonomy, and long-running workflows. Two-Phase Commit favors atomic consistency within a tightly coordinated environment.

Concern Saga Two-Phase Commit
Consistency model Eventual consistency with compensation. Atomic distributed commit.
Intermediate state May be visible. Normally hidden until commit.
Locks Local and short-lived. May remain held after prepare.
Availability Higher Lower during participant or coordinator failures.
Long-running workflow Strong fit Poor fit.
Heterogeneous participants Strong fit Requires compatible transaction support.
Failure recovery Retries and compensating actions. Coordinator recovery and commit or rollback.
Business complexity Compensation logic is explicit. Infrastructure handles rollback.
Typical architecture Microservices and event-driven systems. Enterprise transaction managers and compatible databases.

Rule of Thumb: use Sagas across autonomous services. Consider Two-Phase Commit only when participants support it, transactions are short, and atomicity is worth the availability and operational cost.

Compensating Transactions

Compensation restores an acceptable business state after already committed steps cannot be completed as planned.

Compensation Is Not Rollback

A database rollback erases uncommitted changes. A compensation creates new committed changes that semantically reverse or offset earlier ones.

Original Action Compensation
Reserve inventory Release inventory reservation
Authorize card Void authorization
Capture payment Issue refund
Create shipment Cancel shipment
Send email No true reversal; send correction if necessary.

A refunded payment is not identical to a payment that never happened. Fees, audit history, exchange rates, and customer-visible entries may remain.

Designing Compensations

Compensations should be idempotent, retryable, observable, and safe when the original action did not complete.

  • Use stable business identifiers such as reservation or payment IDs.
  • Record original outcomes before choosing compensation.
  • Make cancellation commands repeatable.
  • Separate compensation status from forward-step status.
  • Define manual recovery when automatic compensation fails repeatedly.

Non-Compensatable Actions

Some actions cannot be fully reversed. Emails may already be read, external transfers may have settled, and physical goods may have shipped.

Irreversible steps should be delayed until the workflow has passed its highest-risk failure points.

Safer order:

1. Reserve inventory
2. Authorize payment
3. Validate shipment
4. Capture payment
5. Create shipment
6. Send confirmation email

Irreversible or costly actions happen later.

Production Note: compensation design often determines step order.

Saga State Machine

An orchestrated saga should be represented as a durable state machine rather than a chain of in-memory method calls.

Saga States

Explicit states make transitions, retries, and recovery auditable.

PENDING
  -> INVENTORY_RESERVING
  -> PAYMENT_AUTHORIZING
  -> SHIPMENT_CREATING
  -> COMPLETED

Failure path:
  PAYMENT_FAILED
  -> INVENTORY_RELEASING
  -> COMPENSATED

Every incoming command result should be valid only for specific current states.

Persistent Progress

The orchestrator must persist progress before or together with publishing the next command.

Stored Field Purpose
saga_id Stable workflow identifier.
state Current workflow position.
version Optimistic concurrency control.
payload Business identifiers and participant results.
attempt_count Retry tracking.
updated_at Detect stalled workflows.

Recovery After a Crash

After restart, the orchestrator should find incomplete sagas and continue from persisted state.

Orchestrator crashes after publishing AuthorizePayment

After restart:
  - load saga state
  - receive duplicate or delayed payment result
  - validate current state
  - continue exactly once logically
  - tolerate at-least-once message delivery

Recovery should not depend on reconstructing state from application logs.

Message Delivery and Consistency

A saga often requires a service to update its database and publish an event. Performing those operations independently creates a dual-write problem.

Transactional Outbox

The transactional outbox stores the business change and outgoing message in one local database transaction.

Local database transaction:

1. Update order
2. Insert outbox message
3. Commit

Separate publisher:
4. Read unpublished outbox messages
5. Publish to broker
6. Mark as published

This guarantees that an event is not lost after the business change commits. It does not guarantee one-time publication, so consumers must remain idempotent.

Inbox and Deduplication

The inbox pattern records processed message IDs in the consumer’s database.

BEGIN;

-- Ignore a duplicate delivery that was already processed.
INSERT INTO message_inbox (
    consumer_name,
    message_id,
    processed_at
)
VALUES (
    'inventory-service',
    :message_id,
    now()
)
ON CONFLICT DO NOTHING;

-- Apply the business change only when the inbox record was newly inserted.

COMMIT;

The inbox row and business update should commit in the same transaction.

At-Least-Once Delivery

Most practical message systems provide at-least-once delivery. A message may be redelivered after consumer failure or acknowledgement loss.

Assumption Production Reality
Each message arrives once Duplicates are possible.
Messages always arrive in order Ordering may be limited to one partition or key.
A timeout means failure Outcome may be unknown.
Publishing and database commit are atomic They require an outbox or compatible transaction mechanism.

Idempotency

Every saga command, event handler, and compensation should tolerate duplicate execution.

Idempotent Commands

A participant should return the existing result when it receives the same logical command again.

ReserveInventory
  saga_id: saga_123
  command_id: cmd_456
  order_id: ord_789

First delivery:
  creates reservation res_100

Duplicate delivery:
  returns existing reservation res_100

Idempotency Keys

Stable keys connect repeated attempts to one logical operation.

Operation Suggested Key
Inventory reservation saga_id + step_name
Payment authorization order_id + payment_attempt
Shipment creation order_id + shipment_version
Compensation original_operation_id + compensation_type

Deduplication Storage

Deduplication state should usually live in the same database transaction as the side effect.

BEGIN;

-- Reserve this command ID. Duplicate attempts insert nothing.
INSERT INTO processed_commands (
    command_id,
    processed_at
)
VALUES (
    :command_id,
    now()
)
ON CONFLICT DO NOTHING;

-- Continue only when the insert affected one row.
-- Apply the inventory reservation in the same transaction.

COMMIT;

Cache-only deduplication can fail if the cache loses data while the business database retains the side effect.

Timeouts, Retries, and Backoff

Distributed workflows must distinguish temporary failures, permanent business failures, and unknown outcomes.

Retryable Failures

Failure Typical Handling
Network timeout Retry idempotently or query operation status.
Service unavailable Retry with backoff and jitter.
Inventory unavailable Permanent business failure; compensate.
Payment declined Permanent for the current method; compensate or request another method.
Invalid command Do not retry unchanged input.

Retry Budgets

Infinite retries hide broken workflows and consume capacity. Each step should define maximum attempts, maximum elapsed time, and escalation behavior.

Payment authorization policy:

initial delay: 1 second
maximum delay: 30 seconds
backoff: exponential
jitter: enabled
maximum attempts: 5
final action: start compensation and alert

Stuck Sagas

A watchdog should identify sagas that remain in one state longer than expected.

  • Retry the current command when safe.
  • Query participant status when the outcome is unknown.
  • Start compensation after a business deadline.
  • Move to manual review when automated resolution is unsafe.
  • Alert with saga ID and current state.

Concurrency and Isolation

Sagas do not automatically isolate intermediate changes from other transactions. Concurrent workflows may compete for the same resources.

Semantic Locking

Semantic locking represents temporary ownership through business state rather than a long database lock.

Inventory item states:

AVAILABLE
  -> RESERVED by saga_123
  -> SOLD

or

RESERVED
  -> RELEASED after compensation

Other workflows should respect the reservation state.

Optimistic Concurrency

Version columns prevent two workers from applying conflicting transitions.

UPDATE sagas
SET
    state = :next_state,
    version = version + 1,
    updated_at = now()
WHERE id = :saga_id
  AND version = :expected_version;

If no row is updated, another worker changed the saga and the transition should be reloaded.

Ordering and Race Conditions

A compensation result may arrive after a retry succeeds, or an old event may arrive after the saga advances.

Current saga state: PAYMENT_AUTHORIZED

Late event arrives:
  InventoryReservationFailed

Handler:
  validate whether event is valid for current state
  ignore or record stale event
  never blindly move state backward

Every handler should verify the current state and expected command or correlation ID.

Production Design Example

Consider an order workflow that reserves inventory, authorizes payment, and creates a shipment.

Order Workflow

1. Order Service creates order with status PENDING
2. Orchestrator sends ReserveInventory
3. Inventory Service creates reservation
4. Orchestrator sends AuthorizePayment
5. Payment Service authorizes payment
6. Orchestrator sends CreateShipment
7. Shipping Service creates shipment
8. Orchestrator marks order COMPLETED

Each participant persists its local state and outgoing result through one local transaction and an outbox record.

Failure Scenarios

Failure Recovery
Inventory unavailable Cancel order; no earlier external side effect requires compensation.
Payment declined Release inventory and cancel order.
Payment timeout Query payment status or retry with the same idempotency key.
Shipment creation fails temporarily Retry until deadline while inventory and payment remain valid.
Shipment cannot be created permanently Void or refund payment, release inventory, cancel order.
Compensation repeatedly fails Move saga to manual intervention and alert operations.

Observability Model

Every log, metric, command, and event should carry the saga ID and business entity ID.

{
  "event": "payment_authorized",
  "saga_id": "saga_123",
  "order_id": "ord_789",
  "command_id": "cmd_456",
  "payment_id": "pay_100",
  "saga_state": "PAYMENT_AUTHORIZING",
  "attempt": 2
}
Metric Purpose
Sagas started Workflow volume.
Sagas completed Successful outcomes.
Sagas compensated Business or technical failure rate.
Saga duration End-to-end latency.
Sagas stuck by state Operational bottlenecks.
Compensation failures Manual recovery risk.

Ready-to-Use Example

The following example implements a durable orchestrated saga with PostgreSQL, a transactional outbox, optimistic concurrency, idempotent message processing, and explicit compensation states.

PostgreSQL Schema

CREATE TABLE order_sagas (
    id text PRIMARY KEY,
    order_id text NOT NULL UNIQUE,
    state text NOT NULL,
    version integer NOT NULL DEFAULT 1,
    payload jsonb NOT NULL DEFAULT '{}'::jsonb,
    attempt_count integer NOT NULL DEFAULT 0,
    last_error text,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);

-- Finds incomplete workflows for watchdog and recovery processing.
CREATE INDEX order_sagas_incomplete_idx
ON order_sagas (
    updated_at
)
WHERE state NOT IN ('COMPLETED', 'COMPENSATED', 'MANUAL_REVIEW');

CREATE TABLE outbox_messages (
    id text PRIMARY KEY,
    aggregate_type text NOT NULL,
    aggregate_id text NOT NULL,
    message_type text NOT NULL,
    payload jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz,
    publish_attempts integer NOT NULL DEFAULT 0
);

-- Allows publishers to scan unpublished messages efficiently.
CREATE INDEX outbox_messages_unpublished_idx
ON outbox_messages (
    created_at
)
WHERE published_at IS NULL;

CREATE TABLE inbox_messages (
    consumer_name text NOT NULL,
    message_id text NOT NULL,
    processed_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (
        consumer_name,
        message_id
    )
);

CREATE TABLE inventory_reservations (
    id text PRIMARY KEY,
    saga_id text NOT NULL UNIQUE,
    order_id text NOT NULL,
    product_id text NOT NULL,
    quantity integer NOT NULL CHECK (quantity > 0),
    status text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);

Python Saga Orchestrator

from __future__ import annotations

import json
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from uuid import uuid4


class SagaState(StrEnum):
    PENDING = "PENDING"
    INVENTORY_RESERVING = "INVENTORY_RESERVING"
    PAYMENT_AUTHORIZING = "PAYMENT_AUTHORIZING"
    SHIPMENT_CREATING = "SHIPMENT_CREATING"
    INVENTORY_RELEASING = "INVENTORY_RELEASING"
    PAYMENT_VOIDING = "PAYMENT_VOIDING"
    COMPLETED = "COMPLETED"
    COMPENSATED = "COMPENSATED"
    MANUAL_REVIEW = "MANUAL_REVIEW"


@dataclass(frozen=True)
class Saga:
    id: str
    order_id: str
    state: SagaState
    version: int
    payload: dict[str, Any]


class ConcurrentSagaUpdate(Exception):
    pass


class InvalidSagaTransition(Exception):
    pass


class SagaRepository:
    def __init__(self, connection: Any):
        self.connection = connection

    def load(self, saga_id: str) -> Saga:
        row = self.connection.execute(
            """
            SELECT id, order_id, state, version, payload
            FROM order_sagas
            WHERE id = %s
            """,
            (saga_id,),
        ).fetchone()

        if row is None:
            raise KeyError(f"Saga not found: {saga_id}")

        return Saga(
            id=row["id"],
            order_id=row["order_id"],
            state=SagaState(row["state"]),
            version=row["version"],
            payload=row["payload"],
        )

    def transition(
        self,
        saga: Saga,
        next_state: SagaState,
        payload: dict[str, Any],
        command_type: str | None = None,
        command_payload: dict[str, Any] | None = None,
    ) -> None:
        # Persist the saga transition and outgoing command in one transaction.
        with self.connection.transaction():
            result = self.connection.execute(
                """
                UPDATE order_sagas
                SET
                    state = %s,
                    version = version + 1,
                    payload = %s::jsonb,
                    updated_at = now()
                WHERE id = %s
                  AND version = %s
                """,
                (
                    next_state.value,
                    json.dumps(payload),
                    saga.id,
                    saga.version,
                ),
            )

            # Optimistic concurrency prevents two workers from progressing
            # the same saga from the same old state.
            if result.rowcount != 1:
                raise ConcurrentSagaUpdate(saga.id)

            if command_type is not None:
                self.connection.execute(
                    """
                    INSERT INTO outbox_messages (
                        id,
                        aggregate_type,
                        aggregate_id,
                        message_type,
                        payload
                    )
                    VALUES (%s, 'order_saga', %s, %s, %s::jsonb)
                    """,
                    (
                        f"msg_{uuid4().hex}",
                        saga.id,
                        command_type,
                        json.dumps(command_payload or {}),
                    ),
                )


class OrderSagaOrchestrator:
    def __init__(self, repository: SagaRepository):
        self.repository = repository

    def start(self, saga_id: str) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.PENDING:
            raise InvalidSagaTransition(
                f"Cannot start saga from state {saga.state}"
            )

        command_id = f"cmd_{uuid4().hex}"

        self.repository.transition(
            saga=saga,
            next_state=SagaState.INVENTORY_RESERVING,
            payload={
                **saga.payload,
                "inventory_command_id": command_id,
            },
            command_type="ReserveInventory",
            command_payload={
                "command_id": command_id,
                "saga_id": saga.id,
                "order_id": saga.order_id,
                "product_id": saga.payload["product_id"],
                "quantity": saga.payload["quantity"],
            },
        )

    def on_inventory_reserved(
        self,
        saga_id: str,
        reservation_id: str,
    ) -> None:
        saga = self.repository.load(saga_id)

        # Duplicate messages are harmless after the saga already advanced.
        if saga.state != SagaState.INVENTORY_RESERVING:
            return

        command_id = f"cmd_{uuid4().hex}"

        self.repository.transition(
            saga=saga,
            next_state=SagaState.PAYMENT_AUTHORIZING,
            payload={
                **saga.payload,
                "reservation_id": reservation_id,
                "payment_command_id": command_id,
            },
            command_type="AuthorizePayment",
            command_payload={
                "command_id": command_id,
                "saga_id": saga.id,
                "order_id": saga.order_id,
                "amount_cents": saga.payload["amount_cents"],
                "payment_method_id": saga.payload["payment_method_id"],
            },
        )

    def on_payment_authorized(
        self,
        saga_id: str,
        payment_id: str,
    ) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.PAYMENT_AUTHORIZING:
            return

        command_id = f"cmd_{uuid4().hex}"

        self.repository.transition(
            saga=saga,
            next_state=SagaState.SHIPMENT_CREATING,
            payload={
                **saga.payload,
                "payment_id": payment_id,
                "shipment_command_id": command_id,
            },
            command_type="CreateShipment",
            command_payload={
                "command_id": command_id,
                "saga_id": saga.id,
                "order_id": saga.order_id,
                "address_id": saga.payload["address_id"],
            },
        )

    def on_shipment_created(
        self,
        saga_id: str,
        shipment_id: str,
    ) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.SHIPMENT_CREATING:
            return

        self.repository.transition(
            saga=saga,
            next_state=SagaState.COMPLETED,
            payload={
                **saga.payload,
                "shipment_id": shipment_id,
            },
            command_type="MarkOrderCompleted",
            command_payload={
                "command_id": f"cmd_{uuid4().hex}",
                "saga_id": saga.id,
                "order_id": saga.order_id,
            },
        )

    def on_payment_failed(
        self,
        saga_id: str,
        error_code: str,
    ) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.PAYMENT_AUTHORIZING:
            return

        # Payment did not complete, so only inventory requires compensation.
        self.repository.transition(
            saga=saga,
            next_state=SagaState.INVENTORY_RELEASING,
            payload={
                **saga.payload,
                "failure_code": error_code,
            },
            command_type="ReleaseInventory",
            command_payload={
                "command_id": f"cmd_{uuid4().hex}",
                "saga_id": saga.id,
                "reservation_id": saga.payload["reservation_id"],
            },
        )

    def on_shipment_failed(
        self,
        saga_id: str,
        error_code: str,
    ) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.SHIPMENT_CREATING:
            return

        # Shipment failed after payment authorization.
        # Void payment first, then release inventory.
        self.repository.transition(
            saga=saga,
            next_state=SagaState.PAYMENT_VOIDING,
            payload={
                **saga.payload,
                "failure_code": error_code,
            },
            command_type="VoidPayment",
            command_payload={
                "command_id": f"cmd_{uuid4().hex}",
                "saga_id": saga.id,
                "payment_id": saga.payload["payment_id"],
            },
        )

    def on_payment_voided(self, saga_id: str) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.PAYMENT_VOIDING:
            return

        self.repository.transition(
            saga=saga,
            next_state=SagaState.INVENTORY_RELEASING,
            payload=saga.payload,
            command_type="ReleaseInventory",
            command_payload={
                "command_id": f"cmd_{uuid4().hex}",
                "saga_id": saga.id,
                "reservation_id": saga.payload["reservation_id"],
            },
        )

    def on_inventory_released(self, saga_id: str) -> None:
        saga = self.repository.load(saga_id)

        if saga.state != SagaState.INVENTORY_RELEASING:
            return

        self.repository.transition(
            saga=saga,
            next_state=SagaState.COMPENSATED,
            payload=saga.payload,
            command_type="MarkOrderCanceled",
            command_payload={
                "command_id": f"cmd_{uuid4().hex}",
                "saga_id": saga.id,
                "order_id": saga.order_id,
                "reason": saga.payload.get("failure_code"),
            },
        )

Production Note: the example keeps transaction handling inside the repository so every state transition and outgoing command commit together.

Outbox Publisher

from __future__ import annotations

import asyncio
import json
from typing import Any


class OutboxPublisher:
    def __init__(
        self,
        connection: Any,
        message_broker: Any,
    ):
        self.connection = connection
        self.message_broker = message_broker

    async def publish_batch(self, batch_size: int = 100) -> int:
        # Lock rows so concurrent publisher processes do not publish
        # the same outbox record at the same time.
        with self.connection.transaction():
            rows = self.connection.execute(
                """
                SELECT
                    id,
                    aggregate_id,
                    message_type,
                    payload
                FROM outbox_messages
                WHERE published_at IS NULL
                ORDER BY created_at
                FOR UPDATE SKIP LOCKED
                LIMIT %s
                """,
                (batch_size,),
            ).fetchall()

            published_ids: list[str] = []

            for row in rows:
                await self.message_broker.publish(
                    topic=row["message_type"],
                    key=row["aggregate_id"],
                    message={
                        "message_id": row["id"],
                        "message_type": row["message_type"],
                        "aggregate_id": row["aggregate_id"],
                        "payload": row["payload"],
                    },
                )

                published_ids.append(row["id"])

            if published_ids:
                self.connection.execute(
                    """
                    UPDATE outbox_messages
                    SET
                        published_at = now(),
                        publish_attempts = publish_attempts + 1
                    WHERE id = ANY(%s)
                    """,
                    (published_ids,),
                )

        # A crash after broker publication but before published_at commits
        # can cause duplicate publication. Consumers must remain idempotent.
        return len(rows)


async def run_publisher(publisher: OutboxPublisher) -> None:
    while True:
        published_count = await publisher.publish_batch()

        # Poll less aggressively when no work exists.
        if published_count == 0:
            await asyncio.sleep(0.5)

Two-Phase Commit SQL Example

PostgreSQL prepared transactions can demonstrate the database side of Two-Phase Commit. Production use also requires a coordinator that durably records the final decision.

-- Participant A: reserve money in the billing database.
BEGIN;

UPDATE accounts
SET balance_cents = balance_cents - 5000
WHERE account_id = 'acct_source'
  AND balance_cents >= 5000;

-- Prepare instead of committing.
-- PostgreSQL keeps the transaction durable and retains required locks.
PREPARE TRANSACTION 'transfer_123_billing';


-- Participant B: credit money in another database connection.
BEGIN;

UPDATE accounts
SET balance_cents = balance_cents + 5000
WHERE account_id = 'acct_destination';

PREPARE TRANSACTION 'transfer_123_ledger';


-- Coordinator records the COMMIT decision durably before notifying participants.

-- Final phase on participant A.
COMMIT PREPARED 'transfer_123_billing';

-- Final phase on participant B.
COMMIT PREPARED 'transfer_123_ledger';

If prepare fails on either participant, the coordinator should abort every participant that prepared successfully:

ROLLBACK PREPARED 'transfer_123_billing';
ROLLBACK PREPARED 'transfer_123_ledger';

Important: abandoned prepared transactions can retain locks and transaction metadata. Monitor them and define an explicit recovery procedure.

Common Mistakes

Distributed transaction failures are usually caused by incomplete failure modeling rather than the absence of a particular framework.

  • Treating a Saga as an automatic rollback mechanism.
  • Publishing events separately from database commits without an outbox.
  • Assuming exactly-once message delivery.
  • Executing non-idempotent participant commands.
  • Retriying unknown payment outcomes with a new idempotency key.
  • Keeping Saga state only in application memory.
  • Skipping optimistic concurrency when several workers can process the same Saga.
  • Compensating steps in the wrong order.
  • Performing irreversible actions too early.
  • Ignoring compensation failures.
  • Allowing infinite retries without deadlines or manual escalation.
  • Using choreography for a workflow too complex to understand operationally.
  • Using one orchestrator for unrelated business domains.
  • Choosing Two-Phase Commit across unreliable or unsupported participants.
  • Leaving prepared transactions unresolved.
  • Assuming Saga-based systems provide transaction isolation automatically.

Production Checklist

A production distributed transaction design should make progress, duplication, compensation, and manual recovery explicit.

  • Keep strong invariants inside local ACID transactions.
  • Choose Saga or Two-Phase Commit from consistency and availability requirements.
  • Use orchestration for complex workflows with branching and deadlines.
  • Use choreography only when the event flow remains understandable.
  • Persist Saga state and version every transition.
  • Store outgoing messages through a transactional outbox.
  • Deduplicate incoming commands and events.
  • Make every forward and compensating action idempotent.
  • Use stable operation and idempotency identifiers.
  • Classify retryable, permanent, and unknown failures.
  • Use exponential backoff with jitter.
  • Define retry limits and business deadlines.
  • Detect stuck Sagas with a watchdog.
  • Define manual-review states and operator procedures.
  • Delay irreversible operations until late in the workflow.
  • Protect state transitions with optimistic concurrency.
  • Validate incoming events against current Saga state.
  • Include Saga IDs in logs, metrics, commands, and events.
  • Monitor completion, compensation, duration, and stuck-state metrics.
  • For 2PC, monitor prepared transactions and coordinator recovery.
  • Load-test participant failures, duplicate delivery, and orchestrator restarts.

Conclusion

Sagas and Two-Phase Commit address distributed consistency through different trade-offs. Sagas commit local work independently and recover through compensation, making them suitable for autonomous services, heterogeneous infrastructure, and long-running workflows.

Two-Phase Commit provides an atomic distributed outcome but introduces blocking, coordinator dependency, retained locks, and stricter participant requirements. It is best reserved for short transactions across compatible systems where atomic commit is more important than availability and autonomy.

Key Takeaway: use local transactions for local invariants, Sagas for most cross-service business workflows, and Two-Phase Commit only when a tightly controlled environment truly requires atomic distributed commit.

Comments (0)

Author

Enjoyed this article?
Support Oleksandr Andrushchenko
This helps Oleksandr Andrushchenko continue creating useful content

Article info

Created: Jul 19
Updated: Jul 21
Published: Jul 19

Article actions

0 Likes
0 Dislikes
Copy persistent article link: