Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 078 / 100

Saga pattern with Spring Boot: best practices explained

Keep data consistent across services without distributed transactions.

Expona Admin9 min read

The consistency challenge at scale

When you split a monolith into microservices, you lose the safety net of a single database transaction. An order placed on your e-commerce platform needs to debit the payment service, reserve inventory, and trigger fulfillment—but what if the inventory service fails after payment succeeds? The saga pattern with Spring Boot: best practices explained gives us a way to keep data consistent across services without relying on distributed transactions, which are slow, complex, and often unavailable in cloud environments.

Sagas coordinate long-running business processes across multiple services by breaking them into smaller, compensable steps. Each step either succeeds or triggers an undo (compensation). We'll walk through two coordination styles, the importance of compensating actions, why the outbox pattern matters, and how idempotency saves us from the retry trap.

Choreography vs orchestration—Two ways to coordinate

The first decision when implementing a saga is who drives the flow. Choreography and orchestration represent opposite ends of the spectrum, each with trade-offs we need to understand.

Choreography: Let events lead

In choreography, services publish events when they complete a step, and other services subscribe and react. There's no central controller. When a payment is processed, the payment service emits PaymentCompleted. The inventory service listens, reserves stock, and publishes StockReserved. The fulfillment service picks that up and ships the order.

Pros: Few steps work well. Each service is loosely coupled. Easy to add new steps without modifying the orchestrator.

Cons: With many steps (say, 7+), the flow becomes hard to visualize and debug. If step 5 fails, you need to trace back through event logs. Testing is tricky because you're wiring together independent event handlers.

When to use: A simple order workflow with 2–4 steps benefits from choreography. The business logic stays in the domain service, not a separate coordinator.

Orchestration: One service conducts

In orchestration, a dedicated saga orchestrator (often a Spring Boot service or a state machine) knows the entire flow and tells each service what to do. It calls the payment service, waits for a response, then calls inventory, then fulfillment. If inventory fails, the orchestrator invokes compensation logic to refund the payment.

Pros: Complex flows with many steps are explicit and testable. You see the happy path and all compensation branches in one place. Easier to add business rules and conditional logic.

Cons: The orchestrator becomes a point of coupling. Every change to the workflow requires a code update. If the orchestrator crashes mid-saga, you need careful state management to resume.

When to use: Multi-step workflows (5+ steps) with complex branching logic call for orchestration. An insurance claim approval with underwriting, documentation checks, and fraud review benefits from a dedicated orchestrator.

A practical comparison

Aspect Choreography Orchestration
Steps Few (2–4) Many (5+)
Coupling Low (event-driven) High (orchestrator owns flow)
Testing Harder (distributed) Easier (centralized)
Debugging Follow event logs Read orchestrator code
Scale complexity Low to medium Medium to high

Compensating actions—The undo button for each step

A saga is only as robust as its compensations. Every step must have a compensating action that reverses its effect. If payment succeeds but inventory fails, you must refund the customer. If fulfillment fails, you must unreserve stock.

Designing compensations

Compensations are not simple rollbacks. You can't undo a payment in a millisecond—the payment processor is another system. Instead, a compensation is a new business action that neutralizes the step.

  • Debit payment: Compensation is credit (refund).
  • Reserve inventory: Compensation is release inventory.
  • Send notification email: Compensation might be send cancellation email (can't unsend, but we inform the customer).
  • Create shipment: Compensation is cancel shipment.

Code example with Spring Boot orchestrator

java
@Service
public class OrderSagaOrchestrator {
    
    @Autowired
    private PaymentClient paymentClient;
    
    @Autowired
    private InventoryClient inventoryClient;
    
    private final SagaRepository sagaRepository;
    
    public void startOrderSaga(OrderSagaRequest request) {
        OrderSaga saga = new OrderSaga(request.getOrderId());
        sagaRepository.save(saga);
        
        try {
            // Step 1: Debit payment
            PaymentResponse paymentResp = paymentClient.debit(request.getAmount());
            saga.setPaymentId(paymentResp.getId());
            saga.setStatus(SagaStep.PAYMENT_COMPLETED);
            sagaRepository.save(saga);
            
            // Step 2: Reserve inventory
            InventoryResponse invResp = inventoryClient.reserve(request.getItems());
            saga.setReservationId(invResp.getId());
            saga.setStatus(SagaStep.INVENTORY_RESERVED);
            sagaRepository.save(saga);
            
            // All steps done
            saga.setStatus(SagaStatus.COMPLETED);
            sagaRepository.save(saga);
            
        } catch (Exception e) {
            // Compensation: reverse steps in reverse order
            compensate(saga);
        }
    }
    
    private void compensate(OrderSaga saga) {
        try {
            if (saga.getStatus().isAtOrBeyond(SagaStep.INVENTORY_RESERVED)) {
                inventoryClient.release(saga.getReservationId());
            }
            if (saga.getStatus().isAtOrBeyond(SagaStep.PAYMENT_COMPLETED)) {
                paymentClient.refund(saga.getPaymentId());
            }
        } catch (Exception compEx) {
            // Log and alert—manual intervention needed
            saga.setStatus(SagaStatus.COMPENSATION_FAILED);
            sagaRepository.save(saga);
        }
        saga.setStatus(SagaStatus.ROLLED_BACK);
        sagaRepository.save(saga);
    }
}

Notice that we save the saga state after each step. This is crucial: if the orchestrator crashes, we resume from the last saved step, not from the beginning.

Use the outbox—Write state and the event in one transaction

The outbox pattern solves a critical race condition: if you publish an event to a message broker after updating your database, and your process crashes between the two, the event is lost. The database shows the action completed, but subscribers never hear about it.

The pattern

Instead of publishing directly to Kafka or RabbitMQ, write the event to an outbox table in your database in the same transaction as your business data. Then a separate polling thread reads the outbox and publishes events to the broker. If publishing fails, the event stays in the outbox and is retried.

Implementation with Spring Boot and PostgreSQL

java
@Entity
@Table(name = "orders")
public class Order {
    @Id
    private String orderId;
    private String status;
    private LocalDateTime createdAt;
}

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String aggregateId;
    private String eventType;
    @Column(columnDefinition = "jsonb")
    private String payload;
    private boolean published;
    private LocalDateTime createdAt;
}

@Service
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private OutboxEventRepository outboxRepository;
    
    @Transactional
    public void createOrder(CreateOrderRequest req) {
        Order order = new Order(req.getOrderId(), "PENDING");
        orderRepository.save(order);
        
        // Write event to outbox in same transaction
        OutboxEvent event = new OutboxEvent();
        event.setAggregateId(order.getOrderId());
        event.setEventType("OrderCreated");
        event.setPayload(jsonSerialize(new OrderCreatedEvent(order.getOrderId(), req.getAmount())));
        event.setPublished(false);
        event.setCreatedAt(LocalDateTime.now());
        outboxRepository.save(event);
        
        // Both writes commit together, or both roll back
    }
}

@Component
public class OutboxPublisher {
    
    @Autowired
    private OutboxEventRepository outboxRepository;
    
    @Autowired
    private KafkaTemplate kafkaTemplate;
    
    @Scheduled(fixedDelay = 1000)
    public void publishPendingEvents() {
        List unpublished = outboxRepository.findByPublishedFalse();
        
        for (OutboxEvent event : unpublished) {
            try {
                kafkaTemplate.send("saga-events", event.getAggregateId(), event.getPayload());
                event.setPublished(true);
                outboxRepository.save(event);
            } catch (Exception e) {
                // Retry on next tick; don't block other events
                log.warn("Failed to publish event {}", event.getId(), e);
            }
        }
    }
}

This approach guarantees at-least-once delivery: if the polling thread crashes before marking an event as published, the next instance will try again. Subscribers must handle duplicates (see idempotency below).

Make steps idempotent—Retries are guaranteed to happen

In a distributed system, retries are not optional—they're inevitable. Network timeouts, temporary service outages, and database connection losses happen regularly. If a service processes the same request twice because of a retry, it must produce the same outcome both times.

Why idempotency matters

If the payment service receives "debit $100" twice due to a network retry, you don't want two charges. If inventory receives "reserve 5 units" twice, it shouldn't reserve 10. Idempotency ensures that no matter how many times we send the same request, the effect is the same as sending it once.

Idempotency keys

The standard approach is an idempotency key: a unique identifier tied to the request. The service stores this key along with the result. If the same key arrives again, we return the cached result instead of re-executing.

java
@Entity
@Table(name = "payment_requests")
public class PaymentRequest {
    @Id
    private String idempotencyKey;
    
    private BigDecimal amount;
    private String orderId;
    private String status;
    private String transactionId;
    private LocalDateTime processedAt;
}

@RestController
@RequestMapping("/payments")
public class PaymentController {
    
    @Autowired
    private PaymentRequestRepository paymentRepository;
    
    @Autowired
    private PaymentProcessor processor;
    
    @PostMapping("/debit")
    public PaymentResponse debit(
            @RequestHeader("Idempotency-Key") String key,
            @RequestBody DebitRequest req) {
        
        // Check if we've already processed this key
        PaymentRequest existing = paymentRepository.findById(key).orElse(null);
        if (existing != null) {
            return new PaymentResponse(existing.getTransactionId(), existing.getStatus());
        }
        
        // First time: process and store
        PaymentRequest record = new PaymentRequest();
        record.setIdempotencyKey(key);
        record.setAmount(req.getAmount());
        record.setOrderId(req.getOrderId());
        
        try {
            String txnId = processor.charge(req.getAmount());
            record.setTransactionId(txnId);
            record.setStatus("SUCCESS");
        } catch (Exception e) {
            record.setStatus("FAILED");
            record.setError(e.getMessage());
        }
        
        record.setProcessedAt(LocalDateTime.now());
        paymentRepository.save(record);
        
        return new PaymentResponse(record.getTransactionId(), record.getStatus());
    }
}

The orchestrator must include an idempotency key (usually derived from the saga ID and step name) in every request. Even if the network drops mid-response, a retry with the same key returns the same result without double-processing.

Common mistakes

  • Ignoring compensation costs: Some compensations (like refunds) take time or money. Plan for them. A refund isn't instant; it's a separate business action.
  • Synchronous compensation: Don't block the saga waiting for compensation to complete. Queue it asynchronously and check its status later.
  • No saga state persistence: If the orchestrator crashes, you must resume from the last saved state. Volatile state (in-memory) causes lost sagas and duplicate compensations.
  • Forgetting the happy path timeout: If a service hangs, your saga hangs. Always set timeouts on inter-service calls and have a saga timeout to trigger compensation.
  • Treating the outbox as optional: Direct event publishing is simpler until it isn't. Use the outbox from day one; it's not much overhead and saves you from eventual consistency nightmares.

Key takeaways

  • Choose choreography for simple flows (2–4 steps); choose orchestration for complex ones with branching logic.
  • Design every step with a clear compensating action that reverses its effect—compensations are business actions, not instant rollbacks.
  • Use the outbox pattern to write state and events in a single transaction, guaranteeing at-least-once event delivery without race conditions.
  • Make all saga steps idempotent using idempotency keys; retries will happen, and services must handle them gracefully.
  • Persist saga state after each step so crashed orchestrators can resume, not restart from the beginning.
  • Set timeouts on all inter-service calls to prevent sagas from hanging indefinitely.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles