Back to Saga & Distributed Transactions
Q1Question 1 of 7

Managing consistency with the Saga pattern when payment fails

Key Concepts
  • @Transactional only wraps one database connection; it cannot roll back commits already made in other services.
  • A Saga models the operation as a sequence of local transactions T1, T2, T3, each committed in its own service.
  • For every forward step with a side effect, you define a compensating transaction that semantically reverses it.
  • Compensations are semantic (release stock, refund payment), not literal rollbacks (delete rows).
  • Non-compensatable steps like physical delivery must be ordered last, after payment succeeds.
  • Orchestration uses a central coordinator with a state machine; choreography chains services via events.
  • Compensations must be idempotent and retryable because the Saga may retry them after transient failures.

Definition

@Transactional cannot span services, so a Saga commits each step locally and, when Payment fails, runs explicit compensating transactions to undo the earlier committed steps.
  • @Transactional limitation – wraps one database connection in one ACID transaction; if it throws, only that one database rolls back, not commits in other services.
  • Saga pattern – models a multi-service operation as a sequence of local transactions, each committed in its own service, with a compensating transaction defined for each forward step.
  • Compensating transaction – a semantic undo that reverses the effect of a committed step (release reserved stock, cancel order), not a literal database rollback.
  • Orchestration – a central coordinator owns the Saga state machine, invokes each step, and drives compensations on failure.
  • Choreography – each service reacts to events from the previous service with no central coordinator.

Imagine Rajesh orders a product through National Bank of India's commerce platform. An order is created, stock is reserved, and then payment fails. These steps happened in three different departments with three different ledgers, so there is no single undo button. Instead, each department that already acted posts a correcting entry: the inventory department releases the reserved stock, the order department cancels the order. The system walks backward with explicit undo steps until everything is consistent again.

Understanding the Concepts

Why is @Transactional not enough for microservices?

A Spring @Transactional method wraps a single database connection in one ACID transaction. If it throws, that database rolls back. But in a microservices flow, the Order Service committed to its own database, the Inventory Service reserved stock in its own database, and the Payment Service runs against a third. When Payment fails, your local rollback cannot reach the other services.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class MonolithicOrderService {

    // In a monolith, this works: one transaction, one database
    @Transactional
    public void placeOrder(String customerId, String productId,
                            BigDecimal amount) {
        orderRepository.createOrder(customerId, productId);   // same DB
        inventoryRepository.reserveStock(productId);           // same DB
        paymentRepository.chargePayment(customerId, amount);   // same DB
        // If payment fails → everything rolls back automatically
    }
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class MicroserviceOrderService {

    // In microservices, this DOES NOT work!
    public void placeOrder(String customerId, String productId,
                            BigDecimal amount) {
        orderClient.createOrder(customerId, productId);     // Service A, DB A
        inventoryClient.reserveStock(productId);             // Service B, DB B
        paymentClient.charge(customerId, amount);            // Service C, DB C
        // If payment fails → order and inventory are ALREADY COMMITTED
        // @Transactional cannot reach DB A and DB B to roll them back
    }
}
DIAGRAM
// Monolith: one @Transactional → one rollback → all or nothing
// Microservices: three services, three databases, three separate commits
// Payment failure → order committed, stock reserved, no way to auto-rollback

What is the Saga pattern?

A Saga models the operation as an ordered series of local transactions T1, T2, T3, each committed immediately in its own service. For each forward step with a side effect, you define a compensating transaction C that semantically reverses it.

JAVA
package com.bank.saga;

import java.math.BigDecimal;

public class OrderSagaDefinition {

    // Forward steps and their compensations:
    // T1: Create Order       → C1: Cancel Order
    // T2: Reserve Stock       → C2: Release Stock
    // T3: Charge Payment      → C3: Refund Payment
    // T4: Schedule Delivery   → (non-compensatable, runs last)

    // If T3 (Payment) fails:
    // Run C2: Release Stock
    // Run C1: Cancel Order
    // Do NOT run T4: Delivery never starts
}

How does the orchestrator drive the Saga?

An orchestrator owns the Saga state machine. It invokes each step, records the state, and on failure drives compensations in reverse order.

JAVA
package com.bank.saga;

import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class OrderSagaOrchestrator {

    private final OrderClient orderClient;
    private final InventoryClient inventoryClient;
    private final PaymentClient paymentClient;
    private final DeliveryClient deliveryClient;
    private final SagaStateRepository sagaRepo;

    public OrderSagaOrchestrator(OrderClient orderClient,
                                   InventoryClient inventoryClient,
                                   PaymentClient paymentClient,
                                   DeliveryClient deliveryClient,
                                   SagaStateRepository sagaRepo) {
        this.orderClient = orderClient;
        this.inventoryClient = inventoryClient;
        this.paymentClient = paymentClient;
        this.deliveryClient = deliveryClient;
        this.sagaRepo = sagaRepo;
    }

    public String executeSaga(String customerId, String productId,
                               BigDecimal amount) {
        String sagaId = "SAGA-" + System.currentTimeMillis();
        sagaRepo.save(sagaId, "STARTED");

        try {
            // T1: Create Order
            String orderId = orderClient.createOrder(customerId, productId);
            sagaRepo.save(sagaId, "ORDER_CREATED");

            // T2: Reserve Stock
            inventoryClient.reserveStock(productId, orderId);
            sagaRepo.save(sagaId, "STOCK_RESERVED");

            // T3: Charge Payment
            paymentClient.charge(customerId, amount, orderId);
            sagaRepo.save(sagaId, "PAYMENT_CHARGED");

            // T4: Schedule Delivery (non-compensatable, runs last)
            deliveryClient.schedule(orderId);
            sagaRepo.save(sagaId, "COMPLETED");

            return orderId;

        } catch (PaymentFailedException e) {
            // Payment failed: compensate in reverse order
            sagaRepo.save(sagaId, "COMPENSATING");
            inventoryClient.releaseStock(productId);  // C2
            orderClient.cancelOrder(customerId);        // C1
            sagaRepo.save(sagaId, "COMPENSATED");
            throw new OrderFailedException(
                    "Payment failed, order compensated: " + e.getMessage());
        }
    }
}
DIAGRAM
// Happy path: T1 → T2 → T3 → T4 → COMPLETED
// Payment fails: T1 → T2 → T3(fail) → C2 → C1 → COMPENSATED
// End state: order CANCELLED, stock released, no money charged

Why must compensations be idempotent?

The Saga may retry a compensation after a transient failure. Releasing stock twice or refunding twice must be safe.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;

@Service
public class InventoryService {

    private final InventoryRepository inventoryRepo;

    public InventoryService(InventoryRepository inventoryRepo) {
        this.inventoryRepo = inventoryRepo;
    }

    // Idempotent compensation: safe to call multiple times
    public void releaseStock(String productId, String orderId) {
        var reservation = inventoryRepo.findByOrderId(orderId);
        if (reservation == null || reservation.isReleased()) {
            return; // Already released or never reserved, no-op
        }
        reservation.release();
        inventoryRepo.save(reservation);
    }
}
DIAGRAM
// First call:  reservation exists, not released → release it
// Second call: reservation exists, already released → no-op
// No reservation: orderId not found → no-op
// Safe to retry without double-releasing

Why must non-compensatable steps run last?

Some steps cannot be undone. Once you physically ship an item, you cannot un-ship it. That is why delivery scheduling always comes after payment succeeds.

JAVA
// Step ordering:
// T1: Create Order       (compensatable: cancel)
// T2: Reserve Stock       (compensatable: release)
// T3: Charge Payment      (compensatable: refund) ← PIVOT POINT
// T4: Schedule Delivery   (non-compensatable: cannot un-ship)

// T4 only runs after T3 succeeds
// If T3 fails → compensate T2 and T1 → delivery never starts
// If T4 fails after T3 succeeds → forward recovery (retry delivery)

How does choreography compare to orchestration for Sagas?

| Aspect | Orchestration | Choreography |
|---|---|---|
| Coordinator | Central orchestrator | None (event-driven) |
| Visibility | State machine shows exact state | Must trace events across services |
| Compensation | Orchestrator drives in order | Each service handles failure events |
| Coupling | Services coupled to orchestrator | Services coupled to event types |
| Complexity | One place for flow logic | Flow scattered across services |
| Best for | Complex flows with compensations | Simple, independent fan-outs |

JAVA
// Choreography version (no coordinator):
// Order Service: creates order → publishes OrderCreated event
// Inventory Service: hears OrderCreated → reserves stock → publishes StockReserved
// Payment Service: hears StockReserved → charges → publishes PaymentCharged
// If Payment fails → publishes PaymentFailed
// Inventory Service: hears PaymentFailed → releases stock
// Order Service: hears PaymentFailed → cancels order

// Problem: who tracks the state? Nobody.
// Problem: compensation order is implicit and fragile

What are common mistakes? (wrong vs correct)

The most common mistake is trying to use two-phase commit across REST services.

JAVA
// WRONG: trying distributed 2PC across REST services
// 1. Begin distributed transaction
// 2. orderService.prepare()     → "PREPARED"
// 3. inventoryService.prepare() → "PREPARED"
// 4. paymentService.prepare()   → timeout!
// 5. Coordinator stuck: rollback the others? What if they already committed?
// 2PC across REST is blocking, slow, and usually not supported

// CORRECT: Saga with compensating transactions
// 1. T1: createOrder() → committed
// 2. T2: reserveStock() → committed
// 3. T3: chargePayment() → FAILED
// 4. C2: releaseStock() → compensated
// 5. C1: cancelOrder() → compensated
// Eventually consistent, no blocking, no coordinator lock

What should a senior engineer know?

A senior engineer uses the transactional outbox to ensure each step and its event are written atomically. They keep explicit Saga state for crash recovery. They prefer orchestration for complex flows with compensations because it is observable and testable as a unit.

JAVA
// Senior checklist for production Sagas:
// 1. Each step: local commit + outbox entry in same transaction
// 2. Compensations: idempotent and retryable
// 3. Non-compensatable steps: ordered last (after pivot point)
// 4. Saga state: persisted for crash recovery and observability
// 5. Orchestrator: state machine (Spring State Machine / Temporal)
// 6. Timeouts: each step has a timeout, expired steps trigger compensation

Visual Overview

DIAGRAM
  Saga: Happy Path
  ────────────────
  Orchestrator    Order Svc    Inventory Svc    Payment Svc    Delivery Svc
      │               │              │               │              │
      │── T1 ────────▶│              │               │              │
      │◀── orderId ──│              │               │              │
      │── T2 ─────────────────────▶│               │              │
      │◀── reserved ──────────────│               │              │
      │── T3 ──────────────────────────────────▶  │              │
      │◀── charged ───────────────────────────── │              │
      │── T4 ─────────────────────────────────────────────────▶  │
      │◀── scheduled ────────────────────────────────────────── │
      │ State: COMPLETED                                         │


  Saga: Payment Fails → Compensate
  ─────────────────────────────────
  Orchestrator    Order Svc    Inventory Svc    Payment Svc
      │               │              │               │
      │── T1 ────────▶│              │               │
      │◀── orderId ──│              │               │
      │── T2 ─────────────────────▶│               │
      │◀── reserved ──────────────│               │
      │── T3 ──────────────────────────────────▶  │
      │◀── FAILED ────────────────────────────── │
      │                                            │
      │── C2: release ────────────▶│               │
      │◀── released ──────────────│               │
      │── C1: cancel ────▶│                        │
      │◀── cancelled ────│                        │
      │ State: COMPENSATED                         │
      │ End: order cancelled, stock released, no charge

Key Takeaway

In a Search, Order, Payment, and Delivery flow, @Transactional cannot keep things consistent because it wraps only one database connection. When the Order Service commits to its own database and the Inventory Service reserves stock in its own database, those commits have already happened in separate processes. When Payment then fails, your local rollback cannot reach them. The Saga pattern handles this by modeling the operation as a sequence of local transactions, each committed immediately in its own service, with a compensating transaction defined for each step that can be undone.

At National Bank of India, when Rajesh placed an order and payment was declined, the orchestrator ran compensations in reverse: the Inventory Service released the two reserved units, and the Order Service cancelled the order. The end state was perfectly consistent with nothing charged, even though no single transaction ever spanned the services. Compensations are semantic, not literal: you release stock rather than rolling back, you refund rather than un-charging, and every compensation is idempotent so it can be safely retried.

Pankaj prefers orchestration over choreography for complex flows because a central orchestrator owns the Saga state machine, explicitly drives each step, and issues compensations from one observable place. Non-compensatable steps like physical delivery are ordered last so they only run after payment succeeds. Each step and its triggering event are written through the transactional outbox so they never drift apart.

Remember one sentence: when @Transactional cannot cross service boundaries, use a Saga that commits each step locally and, on failure, runs idempotent compensating transactions in reverse to reach a clean consistent end state.

---

Share
Use ← → arrow keys to navigate