Back to Blog
spring-bootmicroservicessagakafkadistributed-systemsjava

Saga Pattern in Spring Boot: Manage Distributed Transactions Without 2PC (2026)

Distributed transactions across microservices using 2-phase commit are fragile and slow. The Saga pattern coordinates multi-service operations with compensating transactions.

J

JOptimize Team

May 28, 2026· 10 min read

In a monolith, placing an order involves a single database transaction: reserve inventory, charge payment, create order — all atomic. In microservices, these live in separate services with separate databases. If payment succeeds but inventory is out of stock, you need to refund the payment. If the order is created but shipping fails, you need to cancel the order. This is the distributed transaction problem.

The Saga pattern solves it without 2-phase commit.


What Is a Saga?

A saga is a sequence of local transactions, each publishing an event/message that triggers the next step. If a step fails, compensating transactions undo the previous steps.

SUCCESS PATH:
  OrderService:    CREATE order (PENDING)        → publishes OrderCreated
  InventoryService: RESERVE stock                 → publishes StockReserved
  PaymentService:  CHARGE customer               → publishes PaymentCompleted
  OrderService:    UPDATE order (CONFIRMED)      → publishes OrderConfirmed

FAILURE PATH (payment fails):
  OrderService:    CREATE order (PENDING)        → publishes OrderCreated
  InventoryService: RESERVE stock                 → publishes StockReserved
  PaymentService:  CHARGE fails                  → publishes PaymentFailed
  InventoryService: RELEASE stock (compensate)   → publishes StockReleased
  OrderService:    UPDATE order (CANCELLED)      → publishes OrderCancelled

Choreography Saga (Event-Driven)

No central coordinator — each service listens for events and decides what to do:

// OrderService — starts the saga @Service @RequiredArgsConstructor public class OrderSagaInitiator { private final OrderRepository orderRepo; private final KafkaTemplate<String, Object> kafka; @Transactional public Order placeOrder(PlaceOrderRequest req) { Order order = orderRepo.save(Order.pending(req)); kafka.send("order-events", new OrderCreatedEvent( order.getId(), req.getCustomerId(), req.getItems() )); return order; } } // InventoryService — listens for OrderCreated, reserves stock @Component @RequiredArgsConstructor public class InventoryEventHandler { private final InventoryRepository inventoryRepo; private final KafkaTemplate<String, Object> kafka; @KafkaListener(topics = "order-events", groupId = "inventory-service") @Transactional public void handle(OrderCreatedEvent event) { boolean reserved = inventoryRepo.tryReserve(event.getItems()); if (reserved) { kafka.send("inventory-events", new StockReservedEvent(event.getOrderId(), event.getItems())); } else { kafka.send("inventory-events", new StockUnavailableEvent(event.getOrderId())); } } // Compensating transaction — releases stock if payment failed @KafkaListener(topics = "payment-events", groupId = "inventory-service") @Transactional public void handlePaymentFailed(PaymentFailedEvent event) { inventoryRepo.releaseReservation(event.getOrderId()); kafka.send("inventory-events", new StockReleasedEvent(event.getOrderId())); } } // PaymentService — listens for StockReserved, charges payment @Component public class PaymentEventHandler { @KafkaListener(topics = "inventory-events", groupId = "payment-service") @Transactional public void handle(StockReservedEvent event) { try { paymentGateway.charge(event.getOrderId()); kafka.send("payment-events", new PaymentCompletedEvent(event.getOrderId())); } catch (PaymentException e) { kafka.send("payment-events", new PaymentFailedEvent(event.getOrderId(), e.getMessage())); } } }

Pros: fully decoupled, no single point of failure. Cons: hard to understand the overall flow, hard to debug.


Orchestration Saga (Central Coordinator)

A saga orchestrator manages the state and explicitly calls each service:

@Component @RequiredArgsConstructor public class PlaceOrderSaga { private final OrderRepository orderRepo; private final InventoryClient inventoryClient; private final PaymentClient paymentClient; private final SagaStateRepository sagaStateRepo; @Transactional public void execute(PlaceOrderRequest req) { // Step 1: Create order Order order = orderRepo.save(Order.pending(req)); SagaState state = sagaStateRepo.save(SagaState.start(order.getId())); try { // Step 2: Reserve inventory state.setStep("RESERVE_INVENTORY"); inventoryClient.reserve(order.getId(), req.getItems()); state.setStep("INVENTORY_RESERVED"); // Step 3: Charge payment state.setStep("CHARGE_PAYMENT"); paymentClient.charge(order.getId(), order.getTotal()); state.setStep("PAYMENT_CHARGED"); // Step 4: Confirm order order.setStatus(OrderStatus.CONFIRMED); orderRepo.save(order); state.setStep("COMPLETED"); } catch (InventoryException e) { // No compensation needed — inventory reservation failed order.setStatus(OrderStatus.CANCELLED); orderRepo.save(order); state.setStep("FAILED").setReason(e.getMessage()); } catch (PaymentException e) { // Compensate: release inventory inventoryClient.release(order.getId()); order.setStatus(OrderStatus.CANCELLED); orderRepo.save(order); state.setStep("FAILED").setReason(e.getMessage()); } finally { sagaStateRepo.save(state); } } }

Pros: easy to understand, clear flow, easy to debug (check saga_state table). Cons: orchestrator is a potential coupling point.


Saga State Table

CREATE TABLE saga_state ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), order_id BIGINT NOT NULL, current_step VARCHAR(50) NOT NULL, status VARCHAR(20) NOT NULL, -- RUNNING, COMPLETED, FAILED failure_reason TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() );

The saga state table gives you full visibility into in-progress and failed sagas — queryable, auditable, and retryable.


Handling Failures and Retries

// Replay failed sagas @Scheduled(fixedDelay = 60_000) public void retryFailedSagas() { sagaStateRepo.findByStatusAndCreatedAtAfter("FAILED", LocalDateTime.now().minusHours(24)) .stream() .filter(state -> state.getRetryCount() < 3) .forEach(state -> { try { sagaOrchestrator.resume(state); } catch (Exception e) { state.incrementRetryCount(); sagaStateRepo.save(state); } }); }

Common Mistakes to Avoid

  • Not making compensating transactions idempotent — the same compensation may run multiple times on retry; releasing an already-released reservation must not fail
  • Sagas that are too long — a saga with 10 steps and 10 compensating steps is hard to reason about; split into smaller sagas or simplify the domain
  • No saga state persistence — in-memory saga state is lost on restart; always persist saga state to handle partial failures and restarts
  • Synchronous orchestration with blocking HTTP — if the payment service is slow, the orchestrator thread blocks; use async messaging (Kafka) between saga steps for decoupling

Summary

The Saga pattern replaces 2-phase commit with a sequence of local transactions and compensating rollbacks. Choreography sagas are more decoupled but harder to debug. Orchestration sagas have a central coordinator that's easy to monitor and reason about. Persist saga state in a database table for visibility, auditability, and retry support. Each step must be idempotent because compensating transactions may run multiple times.


Detect Distributed Transaction Issues

JOptimize detects direct cross-service database calls that bypass saga coordination, missing idempotency patterns in event handlers, and Kafka producers called outside the outbox pattern.

Coordinate microservice transactions correctly — free architecture scan.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.