Saga pattern for distributed transactions in microservices

1. Overview

In a monolithic application, you put @Transactional on a method and the database handles the rest: either everything is committed, or everything is rolled back. When you split into microservices, each service has its own database, so a single ACID transaction can no longer cover the entire business operation.

This article introduces the Saga pattern, the most popular way to solve this problem. After reading it, you’ll understand the two types of saga (choreography and orchestration), write a small saga orchestrator in Java 21, test compensation flows, and know common pitfalls when deploying saga to production.

2. The Problem: One Business Operation, Multiple Databases

Consider an order placement flow with three services:

  1. InventoryService holds items in inventory.
  2. PaymentService deducts money from the customer.
  3. ShippingService creates a shipping order.

Suppose step 1 succeeds but step 2 fails because the customer doesn’t have enough money. At this point, the items are held in inventory but no payment order exists. You can’t ‘rollback’ the InventoryService database from PaymentService.

The classical solution is two-phase commit (2PC). However, 2PC is rarely used in microservices because:

  • It locks resources while waiting for all parties to respond, so throughput drops significantly.
  • The coordinator is a single point of failure.
  • Many systems like Kafka, MongoDB, or third-party REST APIs don’t support XA transactions.

3. What is Saga?

Saga breaks a large transaction into a series of local transactions. Each local transaction runs within a service and commits immediately to that service’s database. Each step comes with a compensating transaction, which is a business operation that ‘undoes’ that step.

Step Action Compensation
1 Reserve stock (reserve) Release stock (release)
2 Charge payment (charge) Refund (refund)
3 Create shipment (ship) Cancel shipment (cancel)

If step n fails, saga runs compensation for steps n-1, n-2, …, 1 in reverse order.

Two important differences from true rollback:

  • Compensation does not delete history. It creates a new change, for example, a refund transaction. For a short period, other services may have seen the intermediate state.
  • Saga guarantees eventual consistency, not isolation like ACID.

There are two ways to orchestrate a saga:

  • Choreography: each service listens for events and decides the next step on its own. There is no central orchestrator.
  • Orchestration: an orchestrator calls each service in order and is responsible for running compensation when there’s an error.

4. Orchestration Saga in Java 21

This section builds an orchestrator in pure Java, independent of any framework, so you see the mechanism clearly.

4.1. Defining a Saga Step

import java.util.function.Consumer;

public interface SagaStep<C> {
    String name();
    void execute(C context);
    void compensate(C context);

    static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
        return new SagaStep<>() {
            public String name() { return name; }
            public void execute(C ctx) { action.accept(ctx); }
            public void compensate(C ctx) { compensation.accept(ctx); }
        };
    }
}

Each step has two behaviors: execute for the forward flow and compensate for the rollback flow. The generic C is context shared between steps, for example order information. Factory method of helps declare steps using lambdas concisely.

4.2. Orchestrator

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record SagaResult(boolean success, String failedStep, String reason) {
    static SagaResult ok() { return new SagaResult(true, null, null); }
    static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}

public class SagaOrchestrator<C> {
    private final List<SagaStep<C>> steps;

    public SagaOrchestrator(List<SagaStep<C>> steps) {
        this.steps = List.copyOf(steps);
    }

    public SagaResult run(C context) {
        Deque<SagaStep<C>> completed = new ArrayDeque<>();
        for (SagaStep<C> step : steps) {
            try {
                step.execute(context);
                completed.push(step);
            } catch (RuntimeException ex) {
                compensate(completed, context);
                return SagaResult.failed(step.name(), ex.getMessage());
            }
        }
        return SagaResult.ok();
    }

    private void compensate(Deque<SagaStep<C>> completed, C context) {
        while (!completed.isEmpty()) {
            SagaStep<C> step = completed.pop();
            try {
                step.compensate(context);
            } catch (RuntimeException ex) {
                // Production: save for retry, don't ignore
                System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
            }
        }
    }
}

Key points:

  • completed is a stack (ArrayDeque with push/pop). When a step succeeds, it’s pushed onto the stack, so when we pop we get the exact reverse order.
  • A failed step is not in the stack. That step hasn’t committed anything yet, so there’s no need to compensate.
  • A failed compensation shouldn’t stop the remaining compensations. Therefore, each compensate call is wrapped in its own try/catch.

4.3. Simulated Services

import java.math.BigDecimal;
import java.util.*;

public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}

public class InventoryService {
    private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
    public void reserve(Order o) {
        int left = stock.get(o.sku()) - o.quantity();
        if (left < 0) throw new IllegalStateException("Out of stock");
        stock.put(o.sku(), left);
    }
    public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
    public int available(String sku) { return stock.get(sku); }
}

public class PaymentService {
    private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
    public void charge(Order o) {
        BigDecimal balance = balances.get(o.customerId());
        if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Insufficient funds");
        balances.put(o.customerId(), balance.subtract(o.amount()));
    }
    public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
    public BigDecimal balance(String customerId) { return balances.get(customerId); }
}

public class ShippingService {
    private final Set<String> shipments = new HashSet<>();
    public void ship(Order o) { shipments.add(o.id()); }
    public void cancel(Order o) { shipments.remove(o.id()); }
    public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}

In reality, each method above is a REST/gRPC call or a message sent to another service. This simulation is only for illustration and testing.

4.4. Building an Order Saga

import java.math.BigDecimal;
import java.util.List;

public class OrderSagaDemo {
    public static void main(String[] args) {
        var inventory = new InventoryService();
        var payment = new PaymentService();
        var shipping = new ShippingService();

        var saga = new SagaOrchestrator<Order>(List.of(
            SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
            SagaStep.of("charge-payment", payment::charge, payment::refund),
            SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
        ));

        var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
        var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));

        System.out.println(saga.run(cheap));
        System.out.println(saga.run(pricey));
        System.out.println("Stock: " + inventory.available("BOOK-01"));
        System.out.println("Balance: " + payment.balance("alice"));
    }
}

Output:

SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Insufficient funds]
Stock: 8
Balance: 60

Order ORD-1 runs through all three steps. Order ORD-2 reserves 3 books but fails at the charge payment step. The orchestrator then calls inventory.release, so inventory goes back to 8 (10 − 2 from the first order) instead of 5.

5. Choreography Saga with Events

With choreography, no service ‘knows’ the entire flow. Each service publishes an event when it’s done, and another service reacts to that event. The example below uses Spring’s ApplicationEventPublisher within a single process for illustration. In a real system, replace it with Kafka or RabbitMQ.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}

@Component
public class PaymentHandler {
    private final PaymentService payment;
    private final ApplicationEventPublisher events;

    public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
        this.payment = payment;
        this.events = events;
    }

    @EventListener
    public void on(StockReserved event) {
        try {
            payment.charge(event.order());
        } catch (IllegalStateException ex) {
            events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
        }
    }
}

@Component
public class InventoryCompensationHandler {
    private final InventoryService inventory;

    public InventoryCompensationHandler(InventoryService inventory) {
        this.inventory = inventory;
    }

    @EventListener
    public void on(PaymentFailed event) {
        inventory.release(event.order());
    }
}

PaymentHandler listens for StockReserved. If charging fails, it publishes PaymentFailed. Then InventoryCompensationHandler receives this event and releases the items back to inventory. For the code to run, InventoryService and PaymentService also need to be declared as beans.

Quick comparison of the two styles:

  • Choreography is simple and loosely coupled when the flow has only 2–3 steps. When the flow is longer, logic is scattered across many services, making it hard to follow and prone to event loops.
  • Orchestration centralizes the flow in one place, making it easy to read, test, and save state. In return, the orchestrator becomes a component you need to operate carefully.

6. Testing Compensation Behavior

Add test dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderSagaTest {

    private final InventoryService inventory = new InventoryService();
    private final PaymentService payment = new PaymentService();
    private final ShippingService shipping = new ShippingService();

    private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
        SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
        SagaStep.of("charge-payment", payment::charge, payment::refund),
        SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
    ));

    @Test
    void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
        var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));

        SagaResult result = saga.run(order);

        assertThat(result.success()).isFalse();
        assertThat(result.failedStep()).isEqualTo("charge-payment");
        assertThat(inventory.available("BOOK-01")).isEqualTo(10);
        assertThat(payment.balance("alice")).isEqualByComparingTo("100");
        assertThat(shipping.hasShipment("ORD-9")).isFalse();
    }

    @Test
    void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
        var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));

        assertThat(saga.run(order).success()).isTrue();
        assertThat(inventory.available("BOOK-01")).isEqualTo(9);
        assertThat(payment.balance("alice")).isEqualByComparingTo("70");
        assertThat(shipping.hasShipment("ORD-10")).isTrue();
    }
}

The first test checks the most important aspect of saga: after failure, all side effects have been undone. Inventory goes back to 10, balance remains unchanged, and no shipment is created. The second test confirms that the successful flow commits all three steps.

7. Common Pitfalls and Prevention

Note: The orchestrator in this article keeps state in memory. If the process dies midway, you don’t know which step was reached and can’t compensate. In production, save saga state (current step, completed steps) to database after each step, then allow resuming on restart.

Compensation must be idempotent. Messages can be resent multiple times, so refund running twice shouldn’t refund twice. Save the orderId of processed transactions and skip duplicate requests.

Compensation must never ‘give up’. The catch block logging in section 4.2 is just for illustration. In reality, failed compensation needs to be retried with backoff. After multiple failures, push to a dead-letter queue for human handling.

Dual write. If a service both commits to database and sends a message to Kafka, one of the two operations might fail. Use Transactional Outbox: write the event to an outbox table in the same local transaction, then another process reads that table and publishes.

Lack of isolation. Between charging and refunding, a user might see their balance temporarily decreased. You can reduce this impact with semantic lock, for example by setting order status to PENDING until the saga completes. Another way is to reorder steps, putting the most likely-to-fail step first.

Some steps cannot be compensated. A sent email cannot be unsent. Place such steps (called pivot or retriable step) at the end of the saga, after all steps that can fail have succeeded.

When NOT to use saga:

  • All data is in one database. Then just use local transactions for simplicity.
  • The business requires absolute strong consistency and can’t tolerate intermediate states, for example some core ledger operations. You should reconsider how you’ve divided service boundaries.
  • The flow has only one service writing data, while other services only read.

8. Conclusion

Saga replaces a distributed transaction with a series of local transactions paired with compensating transactions, allowing the system to achieve eventual consistency without needing 2PC. You’ve built a small orchestrator using a stack to compensate in reverse order, seen how choreography coordinates through events, and written tests confirming that all side effects are undone when errors occur.

When deploying saga to production, remember four things: persistently save saga state, write compensation that is idempotent and has retry logic, use Transactional Outbox to avoid dual write, and design the step order so that irreversible operations are at the end of the flow.

microservicessagadistributed transactionspring bootjava