Distributed transactions across microservices using 2-phase commit are fragile and slow. The Saga pattern coordinates multi-service operations with compensating transactions.
JOptimize Team
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.
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
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.
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.
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.
// 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); } }); }
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.
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.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.