00:00

Saga Pattern in Microservices

In a monolithic application, keeping data consistent is fairly simple: one database and ACID transactions (Atomicity, Consistency, Isolation, Durability) make sure an operation either completes fully or not at all. When you split that monolith into microservices, each with its own private database, this simplicity disappears. How do you keep data correct across services during a business process? The answer is often the Saga pattern.

This guide explains the problem, how sagas work, both coordination styles with Java and Spring Kafka code, compensating transactions, reliability techniques and best practices.

1. The Problem: Transactions Across Microservices

Imagine an e-commerce application with separate Order, Payment and Inventory services, each with its own database. When a customer places an order, several steps must happen:

  1. The order service creates a new order with the status PENDING.
  2. The payment service charges the customer.
  3. The inventory service reserves the items in stock.

What if one step fails?

  • If inventory reservation fails after the payment succeeded, you have charged a customer for an order you cannot fulfill.
  • If the payment fails after stock was reserved, you hold stock that nobody can buy.

This is the distributed transaction problem. Each service can only commit or roll back its own database. There is no single transaction that covers all three.

2. Why Not Use a Distributed Transaction (2PC)?

The classic solution for transactions across databases is the two-phase commit (2PC) protocol: a coordinator asks every database to prepare, then tells all of them to commit. It works, but it fits microservices poorly:

Issue with 2PCWhy it matters in microservices
Locks are held until everyone commitsSlow participants block the others and reduce throughput
The coordinator is a single point of failureIf it crashes mid-way, participants can stay locked
All participants must be available at the same timeReduces availability; one service down blocks everything
Many modern databases and message brokers do not support itNoSQL stores and Kafka cannot take part in a classic 2PC
Tight couplingServices must share a transaction protocol, against the idea of independent services

Sagas take a different approach: they give up immediate consistency in exchange for availability and independence, and reach eventual consistency instead.

3. What Is the Saga Pattern?

The Saga pattern manages data consistency across several microservices by splitting one big business transaction into a sequence of local transactions. Each local transaction updates the database of a single service and then triggers the next step.

The key idea: for every step that changes data, there is a matching compensating transaction that undoes its effect. If a step fails, the saga runs the compensating transactions for the completed steps, in reverse order, and the system returns to a consistent state.

Saga with compensation
Happy path:
  T1 Create order  ->  T2 Charge payment  ->  T3 Reserve stock  ->  Done

Failure in T3:
  T1 Create order  ->  T2 Charge payment  ->  T3 Reserve stock  (fails)
                                                      |
  C1 Cancel order  <-  C2 Refund payment  <-----------+

The term comes from a 1987 database research paper that described long-running transactions as sagas: a story told in several chapters, where each chapter can be undone if the story cannot finish.

4. Compensating Transactions: The Heart of the Saga

Compensation is not a database rollback. The earlier steps have already been committed, and other users may have seen their results. A compensating transaction is a business-level undo:

Step (transaction)Compensating transaction
Create orderCancel order (set status to CANCELLED)
Charge paymentRefund payment
Reserve stockRelease stock (return it to the available count)
Book a seatRelease the seat
Send a confirmation emailSend a cancellation email (you cannot “unsend” the first)

Rules for good compensating transactions:

  • They must be idempotent: running a refund twice must not refund twice.
  • They must eventually succeed: retry them until they work; a failed compensation leaves data inconsistent.
  • They are semantic, not exact: a refund does not erase the charge, it adds an opposite entry.
  • Put hard-to-undo steps last: steps that cannot be compensated, such as shipping a parcel, should happen only after every step that might fail. For example, many shops reserve stock before charging the card, because releasing stock is easier and cheaper than refunding.

5. Choreography-Based Saga

In a choreography-based saga there is no central coordinator. Each service listens for events, does its local work and publishes a new event. The services “dance” together by reacting to each other.

5.1 Example: placing an order

  1. The order service creates a PENDING order and publishes OrderCreated.
  2. The payment service hears OrderCreated, charges the customer and publishes PaymentProcessed, or PaymentFailed if the charge fails.
  3. The inventory service hears PaymentProcessed, reserves the stock and publishes InventoryReserved, or InventoryFailed if items are out of stock.
  4. The order service hears InventoryReserved and changes the order to CONFIRMED.

5.2 What happens on failure?

  • If the inventory service publishes InventoryFailed, the payment service hears it and refunds the customer, and the order service hears it and cancels the order.
  • If the payment service publishes PaymentFailed, the order service cancels the order. Nothing else needs to be undone.
Choreography event flow
Order Svc --OrderCreated--> Payment Svc --PaymentProcessed--> Inventory Svc
    ^                           |                                  |
    |                      PaymentFailed                 InventoryReserved
    |                           |                       or InventoryFailed
    +------------- events -------+----------------------------------+
     (confirm or cancel order)      (Payment Svc refunds on InventoryFailed)
ProsCons
Simple to start; no extra coordinator serviceThe overall flow is spread across services and hard to see
Loosely coupled: services only know events, not each otherHarder to debug and test as the number of steps grows
No single point of failureRisk of cyclic dependencies between services’ events

6. Choreography in Java with Spring Kafka

Here is the choreography example with Spring for Apache Kafka. Each event is a small record, and each event type has its own topic to keep things simple:

Events.java (shared event contracts)
public record OrderCreated(String orderId, String customerId, String productId,
                           int quantity, BigDecimal amount) {
}

public record PaymentProcessed(String orderId, String productId, int quantity, BigDecimal amount) {
}

public record PaymentFailed(String orderId, String reason) {
}

public record InventoryReserved(String orderId) {
}

public record InventoryFailed(String orderId, BigDecimal amountToRefund, String reason) {
}

6.1 Order service: starts the saga and reacts to the outcome

OrderSagaHandler.java (order-service)
@Service
public class OrderSagaHandler {

    private final OrderRepository orders;
    private final KafkaTemplate<String, Object> kafka;

    public OrderSagaHandler(OrderRepository orders, KafkaTemplate<String, Object> kafka) {
        this.orders = orders;
        this.kafka = kafka;
    }

    // Step 1: local transaction + event
    @Transactional
    public Order placeOrder(PlaceOrderRequest request) {
        Order order = orders.save(Order.pending(request));
        kafka.send("order-created", order.getId(), new OrderCreated(
                order.getId(), request.customerId(), request.productId(),
                request.quantity(), order.getTotal()));
        return order;
    }

    // Final step on success
    @KafkaListener(topics = "inventory-reserved", groupId = "order-service")
    @Transactional
    public void on(InventoryReserved event) {
        orders.findById(event.orderId()).ifPresent(Order::confirm);
    }

    // Compensation: cancel the order when payment or stock fails
    @KafkaListener(topics = "payment-failed", groupId = "order-service")
    @Transactional
    public void on(PaymentFailed event) {
        orders.findById(event.orderId()).ifPresent(order -> order.cancel(event.reason()));
    }

    @KafkaListener(topics = "inventory-failed", groupId = "order-service")
    @Transactional
    public void on(InventoryFailed event) {
        orders.findById(event.orderId()).ifPresent(order -> order.cancel(event.reason()));
    }
}

6.2 Payment service: charges, and refunds when needed

PaymentSagaHandler.java (payment-service)
@Service
public class PaymentSagaHandler {

    private final PaymentService payments;
    private final KafkaTemplate<String, Object> kafka;

    public PaymentSagaHandler(PaymentService payments, KafkaTemplate<String, Object> kafka) {
        this.payments = payments;
        this.kafka = kafka;
    }

    @KafkaListener(topics = "order-created", groupId = "payment-service")
    public void on(OrderCreated event) {
        try {
            // The order ID is the idempotency key: a duplicate event never charges twice
            payments.charge(event.orderId(), event.customerId(), event.amount());
            kafka.send("payment-processed", event.orderId(), new PaymentProcessed(
                    event.orderId(), event.productId(), event.quantity(), event.amount()));
        } catch (PaymentDeclinedException ex) {
            kafka.send("payment-failed", event.orderId(),
                    new PaymentFailed(event.orderId(), ex.getMessage()));
        }
    }

    // Compensating transaction
    @KafkaListener(topics = "inventory-failed", groupId = "payment-service")
    public void on(InventoryFailed event) {
        payments.refund(event.orderId(), event.amountToRefund());   // idempotent as well
    }
}

6.3 Inventory service: reserves stock

InventorySagaHandler.java (inventory-service)
@Service
public class InventorySagaHandler {

    private final StockService stock;
    private final KafkaTemplate<String, Object> kafka;

    public InventorySagaHandler(StockService stock, KafkaTemplate<String, Object> kafka) {
        this.stock = stock;
        this.kafka = kafka;
    }

    @KafkaListener(topics = "payment-processed", groupId = "inventory-service")
    public void on(PaymentProcessed event) {
        boolean reserved = stock.reserve(event.orderId(), event.productId(), event.quantity());

        if (reserved) {
            kafka.send("inventory-reserved", event.orderId(), new InventoryReserved(event.orderId()));
        } else {
            kafka.send("inventory-failed", event.orderId(),
                    new InventoryFailed(event.orderId(), event.amount(), "Out of stock"));
        }
    }
}

Notice that the order ID is used as the Kafka message key everywhere, so all events for one order stay in order, and as the idempotency key, so duplicate events cause no harm. In production, publish events through the transactional outbox (section 11) so that saving data and sending the event cannot get out of step.

7. Orchestration-Based Saga

In an orchestration-based saga, a central orchestrator manages the whole business process. It tells each participant what to do and when, by sending commands and waiting for replies.

7.1 Example: placing an order

  1. An Order Saga Orchestrator starts when a customer places an order; the order is saved as PENDING.
  2. The orchestrator sends a Process Payment command to the payment service.
  3. If the payment succeeds, it sends a Reserve Inventory command to the inventory service.
  4. If all steps succeed, it tells the order service to Approve Order and marks the saga as complete.

7.2 What happens on failure?

If Reserve Inventory fails, the orchestrator runs the compensating transactions in reverse order:

  1. Send a Refund Payment command to the payment service.
  2. Send a Cancel Order command to the order service.
Orchestration command flow
                  [ Order Saga Orchestrator ]
                   |          |           |
      ProcessPayment   ReserveInventory   ApproveOrder / CancelOrder
                   |          |           |
                   v          v           v
            Payment Svc  Inventory Svc  Order Svc
                   |          |
                   +-- replies (success / failure) --> Orchestrator
ProsCons
The whole flow is in one place: easy to read, change and monitorThe orchestrator is an extra component to build and run
No cyclic dependencies between servicesRisk of putting too much business logic in the orchestrator
Easier to handle complex flows, timeouts and compensationParticipants depend on the orchestrator’s commands

8. An Orchestrator in Java

An orchestrator stores the state of each saga, so it can continue after a restart, and moves to the next step when a reply arrives:

OrderSaga.java
@Entity
@Table(name = "order_sagas")
public class OrderSaga {

    public enum Step { PAYMENT_PENDING, INVENTORY_PENDING, COMPLETED, COMPENSATING, CANCELLED }

    @Id
    private String orderId;

    @Enumerated(EnumType.STRING)
    private Step step;

    private BigDecimal amount;
    private String productId;
    private int quantity;

    protected OrderSaga() {
    }

    public static OrderSaga start(String orderId, BigDecimal amount, String productId, int quantity) {
        OrderSaga saga = new OrderSaga();
        saga.orderId = orderId;
        saga.amount = amount;
        saga.productId = productId;
        saga.quantity = quantity;
        saga.step = Step.PAYMENT_PENDING;
        return saga;
    }

    public void moveTo(Step next) {
        this.step = next;
    }

    public String getOrderId() { return orderId; }
    public Step getStep() { return step; }
    public BigDecimal getAmount() { return amount; }
    public String getProductId() { return productId; }
    public int getQuantity() { return quantity; }
}
OrderSagaOrchestrator.java
@Component
public class OrderSagaOrchestrator {

    private final OrderSagaRepository sagas;
    private final KafkaTemplate<String, Object> kafka;

    public OrderSagaOrchestrator(OrderSagaRepository sagas, KafkaTemplate<String, Object> kafka) {
        this.sagas = sagas;
        this.kafka = kafka;
    }

    @Transactional
    public void start(Order order) {
        OrderSaga saga = sagas.save(OrderSaga.start(
                order.getId(), order.getTotal(), order.getProductId(), order.getQuantity()));
        kafka.send("payment-commands", saga.getOrderId(),
                new ProcessPayment(saga.getOrderId(), order.getCustomerId(), saga.getAmount()));
    }

    @KafkaListener(topics = "payment-replies", groupId = "order-saga")
    @Transactional
    public void onPaymentReply(PaymentReply reply) {
        OrderSaga saga = sagas.findById(reply.orderId()).orElseThrow();
        if (saga.getStep() != OrderSaga.Step.PAYMENT_PENDING) {
            return;   // duplicate or late reply: already handled
        }

        if (reply.success()) {
            saga.moveTo(OrderSaga.Step.INVENTORY_PENDING);
            kafka.send("inventory-commands", saga.getOrderId(),
                    new ReserveInventory(saga.getOrderId(), saga.getProductId(), saga.getQuantity()));
        } else {
            saga.moveTo(OrderSaga.Step.CANCELLED);
            kafka.send("order-commands", saga.getOrderId(),
                    new CancelOrder(saga.getOrderId(), "Payment declined"));
        }
        sagas.save(saga);
    }

    @KafkaListener(topics = "inventory-replies", groupId = "order-saga")
    @Transactional
    public void onInventoryReply(InventoryReply reply) {
        OrderSaga saga = sagas.findById(reply.orderId()).orElseThrow();
        if (saga.getStep() != OrderSaga.Step.INVENTORY_PENDING) {
            return;
        }

        if (reply.success()) {
            saga.moveTo(OrderSaga.Step.COMPLETED);
            kafka.send("order-commands", saga.getOrderId(), new ApproveOrder(saga.getOrderId()));
        } else {
            // Compensate the completed steps in reverse order
            saga.moveTo(OrderSaga.Step.COMPENSATING);
            kafka.send("payment-commands", saga.getOrderId(),
                    new RefundPayment(saga.getOrderId(), saga.getAmount()));
            kafka.send("order-commands", saga.getOrderId(),
                    new CancelOrder(saga.getOrderId(), "Out of stock"));
        }
        sagas.save(saga);
    }
}

The step check at the start of each handler makes the orchestrator idempotent: a duplicate reply is simply ignored. Real orchestrators also handle timeouts, for example compensating if no payment reply arrives within a few minutes.

9. Choreography vs Orchestration

ChoreographyOrchestration
CoordinationDecentralized: services react to eventsCentralized: an orchestrator sends commands
CouplingVery loose; services know only eventsParticipants depend on the orchestrator
Visibility of the flowSpread across servicesIn one place
Best forSimple flows with 2–4 stepsComplex flows, many steps, branches and timeouts
Adding a stepChange several services’ event handlingChange the orchestrator
DebuggingHarder: follow events across servicesEasier: check the saga’s state
Single point of failureNoneThe orchestrator (run several instances)

Many teams start with choreography for simple flows and switch to orchestration when a flow grows past a few steps or needs complex error handling.

10. Saga States and Pending Data

Because a saga takes time to finish, data passes through intermediate states. Model these states explicitly, so everyone, including users, knows the data is not final yet:

Order status life cycle
            +--> CONFIRMED  (all steps succeeded)
            |
 PENDING ---+
            |
            +--> CANCELLED  (a step failed; compensations ran)
  • Show users honest messages, such as “Your order is being processed”, while the status is PENDING.
  • Do not let other processes treat PENDING data as final, for example by shipping a pending order.
  • Record why an order was cancelled, so support teams and customers understand what happened.

11. Making Sagas Reliable

Sagas depend on messages being delivered and processed correctly. These techniques make them reliable:

TechniqueProblem it solves
Idempotent handlersMessages can be delivered more than once; handling a duplicate must not charge or refund twice
Transactional outboxSaving data and publishing an event are two separate actions; the outbox writes the event into the same database transaction, and a relay publishes it afterwards
Retries with backoffTemporary failures should not end a saga; see the Retry pattern
TimeoutsA missing reply should not leave a saga waiting forever; compensate after a deadline
Dead-letter topicsA message that keeps failing is set aside and investigated, instead of blocking others
Correlation IDs and tracingFollow one saga across all services and logs
Outbox idea (order-service)
@Transactional
public Order placeOrder(PlaceOrderRequest request) {
    Order order = orders.save(Order.pending(request));

    // Same transaction: either both rows are saved, or neither is
    outbox.save(new OutboxMessage("order-created", order.getId(), toJson(order)));

    return order;
}
// A separate relay (or a tool such as Debezium) reads new outbox rows and publishes them to Kafka

12. Isolation Problems and How to Handle Them

ACID transactions provide isolation: other users never see half-finished work. Sagas do not, because each step commits on its own. This can cause anomalies, such as another request reading an order that is about to be cancelled. Common countermeasures:

CountermeasureHow it helpsExample
Semantic lockMark data as “in progress” so others treat it carefullyPENDING orders cannot be shipped or edited
Commutative updatesDesign updates so their order does not matterAdd and subtract stock amounts instead of overwriting the total
Reread valueCheck that data has not changed before updating itUse version numbers (optimistic locking)
Pessimistic orderingRun risky steps early and irreversible steps lastReserve stock before charging; ship only after everything succeeded

13. Frameworks and Tools for Sagas

ToolTypeNotes
Spring for Apache Kafka / RabbitMQMessagingBuild choreography or simple orchestration yourself, as shown above
Axon FrameworkJava frameworkSagas, event sourcing and CQRS for Java
Eventuate Tram SagasJava frameworkOrchestration sagas with a built-in transactional outbox
TemporalWorkflow engineWrite long-running workflows as normal code; retries and state are handled for you
Camunda / ZeebeWorkflow engineModel flows visually with BPMN diagrams
AWS Step FunctionsManaged serviceOrchestrate steps, including compensation, in AWS

For simple sagas, plain messaging is enough. For complex, long-running processes with many steps and timeouts, a workflow engine saves a lot of custom code.

14. When to Use the Saga Pattern

Use a saga when:

  • A business transaction spans several microservices, each with its own database.
  • You need data consistency without two-phase commit or a shared database.
  • Each step can be reversed with compensating logic, or placed after all steps that might fail.
  • The business accepts eventual consistency: a short delay before everything agrees.

Think twice when:

  • All the data lives in one service; a normal local transaction is simpler.
  • The business needs strict, immediate consistency everywhere; perhaps the data belongs in one service.
  • A saga would need many services for one simple action; the service boundaries may be wrong. See Domain-Driven Design.

15. Best Practices

  1. Design compensations first: for every step, decide how it will be undone before writing code.
  2. Make every handler and compensation idempotent, using the saga or order ID as the key.
  3. Use the transactional outbox to publish events and commands reliably.
  4. Order steps carefully: risky steps early, irreversible steps last.
  5. Model intermediate states, such as PENDING, and show them honestly to users.
  6. Add timeouts so a saga never waits forever.
  7. Keep sagas short, with as few steps and services as possible.
  8. Choose the style deliberately: choreography for simple flows, orchestration for complex ones.
  9. Trace and monitor sagas: how many are running, completed, compensated or stuck.
  10. Test failure paths, not just the happy path: every compensation must be tested.

16. Common Mistakes to Avoid

MistakeWhat goes wrongBetter approach
No compensation for a stepFailures leave data permanently inconsistentDefine a compensating action for every step
Non-idempotent handlersDuplicate messages cause double charges or refundsUse the saga ID as an idempotency key
Saving data and sending events separatelyA crash in between loses the event and stalls the sagaUse the transactional outbox
Irreversible steps too earlyCannot undo, for example, a shipped parcelRun irreversible steps last
No timeoutsSagas stay pending forever when a reply is lostCompensate after a deadline
Treating pending data as finalPending orders get shipped or reported as salesUse semantic locks and clear states
Huge choreography websNobody understands the full flowSwitch to orchestration for complex flows
Testing only the happy pathCompensation bugs appear in productionTest every failure scenario

17. Summary

The Saga pattern is an essential tool for keeping data consistent in microservices without distributed transactions. It splits a business transaction into a sequence of local transactions, each with a compensating transaction that undoes it if a later step fails. Choose choreography for simple, loosely coupled flows driven by events, and orchestration for complex flows managed by a central coordinator. Make every step and compensation idempotent, publish messages reliably with the outbox pattern, model pending states, add timeouts, and test every failure path. Sagas trade the immediate consistency of ACID transactions for the scalability and independence of microservices, embracing eventual consistency as a core principle of distributed systems. Continue with Event-Driven Architecture to learn more about the events that power sagas.