00:00

Saga Pattern in Microservices

Last updated:

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 separate 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 how sagas work, both coordination styles with Java and Spring Kafka code, compensating transactions, reliability techniques and best practices.

1. 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 compensating transactionsSuccess path: T1 create order, T2 charge payment, T3 reserve stock, then done. Failure path: T3 reserve stock fails, so the saga runs C2 refund payment and then C1 cancel order, in reverse order.SuccessT1Create orderT2Charge paymentT3Reserve stockDone ✔order CONFIRMEDFailure in T3T1Create orderT2Charge paymentT3 ✘Reserve stock failsC2Refund paymentC1Cancel ordercompensations runin reverse order
Figure 1: each step commits on its own. If a later step fails, compensating transactions undo the earlier steps, newest first.

2. 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.

3. 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.

3.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.

3.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-based saga event flowSuccess: order service publishes OrderCreated, payment service charges and publishes PaymentProcessed, inventory service reserves stock and publishes InventoryReserved, and the order service confirms the order. Failure: inventory service publishes InventoryFailed; the payment service refunds and the order service cancels the order.SuccessOrder ServicePayment ServiceInventory ServiceOrderCreatedPaymentProcessedInventoryReserved → order becomes CONFIRMEDInventory failsOrder ServicePayment ServiceInventory ServiceOrderCreatedPaymentProcessedInventoryFailedPayment Service refunds the charge; Order Service cancels the order
Figure 2: in choreography there is no coordinator. Each service reacts to events (sent through Kafka topics) and publishes its own.
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

4. 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) {
}

4.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()));
    }
}

4.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
    }
}

4.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 9) so that saving data and sending the event cannot get out of step.

5. 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.

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.

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-based saga command flowThe Order Saga Orchestrator sends commands: 1 ProcessPayment to the payment service, 2 ReserveInventory to the inventory service, 3 ApproveOrder to the order service. Each participant sends a reply back. On failure the orchestrator sends RefundPayment and CancelOrder instead.Order Saga Orchestratorstores each saga's current step and status1. ProcessPaymentreplyPayment Service2. ReserveInventoryreplyInventory Service3. ApproveOrderreplyOrder Servicesolid purple = command · dashed green = reply (success / failure)on failure: RefundPayment → Payment Service, then CancelOrder → Order Service
Figure 3: in orchestration one component tells each participant what to do and reacts to their replies.
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

6. 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.

7. 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.

8. 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 cycleAn order starts as PENDING while the saga runs. It becomes CONFIRMED when all steps succeed, or CANCELLED when a step fails and the compensations have run.PENDINGsaga in progressCONFIRMEDall steps succeededCANCELLEDcompensations ranInventoryReservedPaymentFailed / InventoryFailed
Figure 4: PENDING tells everyone the result isn't final yet; only CONFIRMED orders may be shipped.
  • 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.

9. 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
Transactional outboxInside one local database transaction the order service writes the order row and an outbox row. A separate relay, for example Debezium, reads new outbox rows and publishes them to Kafka, where the payment and inventory services consume them.Order ServiceplaceOrder()one DB transactionordersnew rowoutboxevent rowRelayDebezium / pollerKafkaorder-created→ Payment, Inventoryboth rows commit together, or neither doespublished at least once
Figure 5: the outbox makes "save the data" and "publish the event" one atomic step, so they can't get out of sync.
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

10. 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

11. 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.

12. 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.

13. 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.

14. 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

15. 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.