HomeStackProjExpSysArtArch
Articles

Distributed Transactions in Microservices: Why Consistency Becomes Difficult

0%
All Articles
System Design
Featured

Distributed Transactions in Microservices:
Why Consistency Becomes Difficult

ACID guarantees hold inside a single database. The moment you split business operations across microservices, you lose the atomicity safety net. This article dissects why distributed consistency is hard, what the industry-standard patterns actually cost, and how to make pragmatic architectural decisions.

$Ibrahim YemiMay 18, 202618 min read
Distributed SystemsMicroservicesSAGAConsistencyArchitecture

In a monolith backed by a single relational database, wrapping a multi-step operation in a transaction is trivial. You call BEGIN, execute your queries, and either COMMIT everything or ROLLBACK on failure. The database engine guarantees ACID: you either end in a consistent state, or you don't move at all.

Microservices dissolve this guarantee entirely.

When an operation spans multiple services — each owning its own database — no single transaction boundary encloses all the state changes. Service A can successfully write its record, then Service B can fail halfway through its write. Now you have partial writes across system boundaries with no automatic rollback mechanism.

This isn't a minor operational inconvenience. It is a fundamental architectural constraint with no clean solution. Every approach involves surrendering something: either strict consistency, availability, or operational complexity budget.


The consequences of ignoring this problem show up in production as business-critical failures:

  • Double charges — a payment is captured but the order record fails to create, so the customer is charged but receives nothing
  • Inventory oversell — stock is reserved in the inventory service but the order is never confirmed in the order service, and that reservation is never released
  • Ghost bookings — a booking is created in the booking service, but the calendar service fails to block the time slot, leading to conflicting reservations
  • Phantom records — data exists in a downstream service with no parent record upstream, making reconciliation reports incorrect

These are not edge cases that happen once a month. In high-throughput systems, partial failures at the 0.1% rate translate to thousands of corrupted records per hour. The financial and reputational cost compounds fast.

Production Reality

Teams that delay solving distributed consistency usually fix it reactively — after a production incident. The architectural surface area for failure grows with every new service added, making it progressively harder to retrofit consistency guarantees later.


Consider an e-commerce checkout operation. Placing an order requires coordination across at least four services:

ServiceOperationState Change
InventoryReserve item stockDecrease available quantity
PaymentCharge the customerCapture funds from payment gateway
OrderCreate order recordPersist order with CONFIRMED status
NotificationSend confirmation emailEnqueue email job

In a monolith, all four writes happen inside one database transaction. In a microservices architecture, each service owns its own database. There is no mechanism for a single transaction to span all four.

A failure scenario that will eventually happen in production:

  1. Inventory reserves stock — succeeds
  2. Payment captures the charge — succeeds
  3. Order service writes the record — fails (database timeout, OOM, deployment mid-write)
  4. The payment is captured but no order record exists
  5. The customer is charged; inventory is depleted; the system has no record of the order

Now you need compensating logic: refund the payment, release the inventory reservation. But what if the notification service had already queued the email? What if the inventory release fails? You're now debugging cascading partial states across four autonomous services.


Two-Phase Commit (2PC) is the classical distributed transaction protocol. A coordinator service drives all participant services through two phases:

  1. Prepare phase — The coordinator asks each participant: "Can you commit?" Each participant acquires locks, writes to a prepare log, and responds YES or NO.
  2. Commit phase — If all participants voted YES, the coordinator broadcasts COMMIT. On any NO, it broadcasts ROLLBACK.

This preserves ACID semantics across distributed participants. So why don't microservices architectures use it universally?

The coordinator is a single point of failure. If the coordinator crashes between the prepare and commit phases, all participants hold locks indefinitely. The system is blocked. This is called the blocking problem of 2PC — no participant can unilaterally resolve the uncertainty, so they wait.

It doesn't scale horizontally. During the prepare phase, all participants hold locks. In a high-throughput system, this creates a global lock contention problem. Latency compounds: the total transaction time is the sum of all network round-trips, plus the slowest participant.

It's incompatible with cloud-native patterns. Auto-scaling services, rolling deployments, and ephemeral containers undermine the coordinator reliability assumption. A coordinator instance that disappears mid-transaction leaves the system in an unresolvable state.

2PC belongs in tightly coupled, low-throughput systems with stable infrastructure — not in the distributed microservices landscape.

SAGA is the industry-standard answer to distributed transactions. Instead of a single atomic transaction, a SAGA is a sequence of local transactions, each committed independently. Consistency is achieved through compensating transactions — operations that semantically undo the effect of a prior step when a downstream failure is detected.

The checkout SAGA looks like:

T1: Reserve inventory          → undo: C1 (Release reservation)
T2: Capture payment            → undo: C2 (Issue refund)
T3: Create order record        → undo: C3 (Mark order CANCELLED)
T4: Enqueue confirmation email → undo: C4 (Dequeue / suppress email)

If T3 fails, the SAGA engine executes C2 (refund) then C1 (release inventory) in reverse order. The system reaches a consistent "nothing happened" state — eventually.

Two SAGA coordination models:

Choreography — Services publish domain events that trigger subsequent steps. There is no central coordinator. The Inventory service publishes StockReserved. The Payment service listens and publishes PaymentCaptured. The Order service listens and creates the order.

Choreography is simple to implement and has no coordinator bottleneck. The downside is observability: the transaction's state is implicit — distributed across event logs across multiple services. Debugging failures requires correlating events across service boundaries.

Orchestration — A dedicated SAGA orchestrator (often a service or a workflow engine) drives the sequence explicitly. It calls each service and decides what to do based on the result. The orchestrator knows the full transaction state at any point.

Orchestration makes the business logic explicit and auditable. The tradeoff: the orchestrator is a coordination bottleneck and a failure point. An orchestrator crash mid-transaction must be handled through durable state persistence (typically in a database backing the orchestrator process).

Choosing Between Models

Choreography fits simple, linear flows with well-bounded services. Orchestration is better for complex multi-step flows with many conditional branches or rollback logic. For anything non-trivial, orchestration's explicit state visibility is worth the coordination overhead.


The CAP theorem is relevant here. In the face of a network partition, a distributed system must choose between consistency (every node sees the same data) and availability (the system keeps responding). SAGA patterns choose availability and eventual consistency — the system remains operational, but it may temporarily violate invariants.

This means during the window between T2 (payment captured) and T3 (order created), the system is in an intermediate inconsistent state. If you query the payment service, the charge exists. If you query the order service, no order exists yet. This intermediate visibility is unavoidable with SAGA.

For most business domains, this is acceptable. For financial systems with regulatory consistency requirements, it requires careful design.

Compensating transactions can be triggered synchronously (the orchestrator calls the compensating endpoints directly) or asynchronously (the compensation is published as a command to a queue).

Synchronous compensation is simpler but creates temporal coupling: if the compensation endpoint is unavailable when the orchestrator needs to call it, the compensation fails and must be retried. If the orchestrator crashes before completing all compensations, you have partial rollback.

Asynchronous compensation through durable queues (SQS, Kafka, RabbitMQ) is more resilient: the compensation command is persisted before the compensating service processes it. But it introduces at-least-once delivery semantics — compensating operations must be idempotent.

Idempotency is non-negotiable. A compensation that's applied twice must produce the same result as applying it once. Issue a refund twice and you've paid the customer double. Release inventory twice and you've over-counted stock. Every compensating transaction must check whether it's already been applied.


Here is a concrete orchestration-based SAGA implementation in PHP using Laravel:

class CheckoutSagaOrchestrator
{
    public function execute(CheckoutCommand $command): CheckoutResult
    {
        $sagaId = Str::uuid();
        $saga   = SagaState::create([
            'id'     => $sagaId,
            'type'   => 'checkout',
            'status' => SagaStatus::STARTED,
            'payload'=> $command->toArray(),
        ]);

        try {
            $reservation = $this->inventoryService->reserve(
                $command->itemId,
                $command->quantity,
                $sagaId,
            );

            $saga->update(['step' => 'inventory_reserved', 'reservation_id' => $reservation->id]);

            $payment = $this->paymentService->capture(
                $command->customerId,
                $command->amount,
                $sagaId,
            );

            $saga->update(['step' => 'payment_captured', 'payment_id' => $payment->id]);

            $order = $this->orderService->create(
                $command,
                $reservation->id,
                $payment->id,
                $sagaId,
            );

            $saga->update(['step' => 'order_created', 'order_id' => $order->id, 'status' => SagaStatus::COMPLETED]);

            return CheckoutResult::success($order->id);

        } catch (Throwable $e) {
            $this->compensate($saga, $e);
            return CheckoutResult::failure($e->getMessage());
        }
    }

    private function compensate(SagaState $saga, Throwable $reason): void
    {
        $saga->update(['status' => SagaStatus::COMPENSATING, 'failure_reason' => $reason->getMessage()]);

        match ($saga->step) {
            'order_created'      => $this->rollbackOrder($saga),
            'payment_captured'   => $this->rollbackPayment($saga),
            'inventory_reserved' => $this->rollbackInventory($saga),
            default              => null,
        };

        $saga->update(['status' => SagaStatus::COMPENSATED]);
    }

    private function rollbackOrder(SagaState $saga): void
    {
        $this->orderService->cancel($saga->order_id, $saga->id);
        $this->rollbackPayment($saga);
    }

    private function rollbackPayment(SagaState $saga): void
    {
        $this->paymentService->refund($saga->payment_id, $saga->id);
        $this->rollbackInventory($saga);
    }

    private function rollbackInventory(SagaState $saga): void
    {
        $this->inventoryService->release($saga->reservation_id, $saga->id);
    }
}

The SAGA ID ($sagaId) is passed to every service call. Each service uses it as an idempotency key — if it's called again with the same SAGA ID, it returns the existing result rather than executing again. The SagaState record acts as the transaction log: the orchestrator can recover from a crash by reading the last committed step and resuming compensation from there.


Because each step commits independently, intermediate states are visible to concurrent reads. A customer's order might appear in a PENDING state visible to support tooling before the payment is captured. If the payment fails, support has already seen a record that will be cancelled. Design your state machines defensively: expose only states that are meaningful to downstream consumers.

Compensation failures are the second-order problem. What happens if the inventory service is down when the orchestrator tries to release a reservation? You must persist compensation commands durably and retry them until they succeed. An outbox table per service or a dedicated compensation queue provides this durability guarantee.

If each service call has a 3-second timeout and there are 5 steps, your worst-case checkout latency is 15 seconds before the orchestrator detects failure and starts compensating. Set aggressive per-step timeouts, use circuit breakers, and monitor p99 latencies for each SAGA step independently.

When a service both writes to its own database and publishes an event, those two operations are themselves not atomic. If the database write succeeds but the event publish fails (or vice versa), downstream services get inconsistent signals.

The canonical solution is the Outbox Pattern: write the event as a record in the same database transaction as the business data, then use a separate process to relay outbox records to the message broker. This makes the local write and the event emission atomic via the database's own transaction guarantee.

// Atomic: business write + outbox record in one transaction
DB::transaction(function () use ($reservation, $sagaId) {
    $reservation->save();

    OutboxEvent::create([
        'aggregate_id' => $reservation->id,
        'event_type'   => 'StockReserved',
        'payload'      => $reservation->toArray(),
        'saga_id'      => $sagaId,
        'published'    => false,
    ]);
});
// A separate relay process reads OutboxEvent where published = false and publishes to broker

Don't Skip the Outbox

The dual write problem is subtle and frequently overlooked. Teams that publish events inline (outside a transaction) will eventually lose events under failure conditions, causing downstream services to miss state transitions silently.


Every SAGA produces a state record. In a high-throughput system, this table grows fast. Completed SAGAs must be archived or pruned on a schedule. Index the table on status, created_at, and type. Consider a separate SAGA store if your primary database is under heavy write pressure.

Not all SAGA steps need to be sequential. Inventory reservation and payment authorization are often independent and can run in parallel. Use async/await or fan-out patterns to parallelize independent steps, reducing total SAGA wall-clock time significantly.

// Parallel execution of independent steps
[$reservation, $paymentAuth] = await([
    $this->inventoryService->reserveAsync($command->itemId, $sagaId),
    $this->paymentService->authorizeAsync($command->amount, $sagaId),
]);

// Proceed to capture and order creation only after both succeed

Under retries and parallel requests, the same SAGA step can be invoked multiple times concurrently. Use database-level unique constraints on (resource_id, saga_id) pairs to enforce idempotency at the persistence layer — application-level checks alone are insufficient under concurrent writes.

Distributed transaction debugging requires first-class observability. Instrument every SAGA step with:

  • Structured logs including saga_id, step, status, duration_ms
  • Distributed traces (OpenTelemetry) with the SAGA ID as a correlation token across service boundaries
  • Metrics on SAGA completion rates, compensation rates by step, and step p99 latencies
  • Alerting on SAGAs stuck in COMPENSATING status beyond a configurable threshold

A SAGA stuck in compensation means a compensating service is unavailable or the compensation logic has a bug. These require human intervention. Visibility is the only way to detect them before users start calling support.


Distributed transactions are an inherent complexity of the microservices tradeoff. The architectural question isn't whether to deal with this complexity — it's which approach best fits your consistency requirements, throughput expectations, and operational maturity.

Decision framework:

RequirementApproach
Strict ACID across 2–3 services, low throughputConsider 2PC or redesign the service boundary
Eventual consistency acceptable, moderate complexitySAGA Choreography
Complex multi-step flows, rollback logic, auditabilitySAGA Orchestration
High throughput, event-driven architectureSAGA + Outbox Pattern
Reads need to reflect committed state onlyRead from projections built from committed events

The deeper insight is this: distributed transaction problems are often a signal that service boundaries were drawn incorrectly. Before investing in SAGA infrastructure, ask whether the operations that need to be consistent belong in the same service. Sometimes the right fix is fewer services, not more coordination machinery.

When you do need cross-service consistency, design for failure from day one. Build idempotency into every service endpoint, instrument every SAGA step before launch, and test compensation paths explicitly. Production systems fail in partial ways — the only robust position is one that assumes partial failure and handles it gracefully.

The SAGA pattern doesn't eliminate distributed transaction complexity. It makes that complexity explicit, manageable, and testable. That's the most you can ask for in a distributed system.

— end of articleBack to all articles

Work with me

Designing a complex system?

Available for engagements in distributed systems architecture, backend design, and engineering leadership.

Get in touch