- Published on
Saga Pattern in Action: A Practical Guide to Distributed Transactions in Spring Boot
- Authors

- Name
- Vic Chen
When we move from a monolithic architecture to microservices, we gain flexibility and scalability. But we also face a significant challenge: How do we maintain data consistency across service boundaries? In a monolith, a single ACID transaction can save the day. In the world of distributed services, that's a luxury we can't afford.
This is where the Saga pattern comes in. It's a powerful way to manage data consistency in a distributed environment without relying on distributed locks or two-phase commits, which often introduce tight coupling and performance bottlenecks.
In this guide, you'll learn:
- What the Saga pattern is and its core concepts.
- The two main implementations of Sagas: Choreography and Orchestration.
- How to implement a practical, choreography-based Saga using Java, Spring Boot, and Spring Cloud Stream.
Core Concepts of Sagas
A Saga is a sequence of local transactions. Each local transaction updates the database within a single service and then publishes a message or event to trigger the next local transaction in the next service. If any transaction fails, the Saga executes a series of compensating transactions to undo the changes made by the preceding transactions.
Key Ingredients
Local Transactions: Each service in the Saga performs its own transaction atomically. For example, the
Payment Serviceis only responsible for the payment transaction. It knows nothing about creating orders or updating stock.Compensating Transactions: This is the crucial part for maintaining consistency. For every operation, you must define a corresponding "undo" operation. For example, if a
ProcessPaymentaction succeeds but the laterUpdateStockaction fails, aRefundPaymentcompensating transaction must be executed.
Two Types of Sagas: Choreography vs. Orchestration
There are two common ways to coordinate a Saga.
1. Choreography: In this approach, there's no central coordinator. Each service participates by publishing and subscribing to events. When one service completes its transaction, it publishes an event that triggers the next service(s) in the chain.
2. Orchestration: This approach uses a central "orchestrator" that is responsible for telling the participant services what to do and in what order. The orchestrator communicates with each service directly (e.g., via command messages) and waits for a reply before moving to the next step.
Here's a diagram to illustrate the difference:
Choreography-Based Saga
Orchestration-Based Saga
Implementation Deep Dive: A Choreography-Based Saga
Let's build a choreography-based Saga for a simple e-commerce order flow. The scenario involves three services: Order Service, Payment Service, and Stock Service. We'll use Spring Boot and Spring Cloud Stream with RabbitMQ as the message broker.
The Scenario
- A customer places an order.
- The
Order Servicecreates an order in aPENDINGstate and publishes anOrderCreatedevent. - The
Payment Servicelistens for this event, processes the payment, and publishes aPaymentProcessedevent. - The
Stock Servicelistens for the payment event and updates the stock. - Finally, the
Order Servicelistens for the stock update to mark the order asCOMPLETED.
Step 1: Order Service
The Order Service kicks off the process. It saves the order and publishes an event.
// Order.java - Simplified Entity
public class Order {
private UUID id;
private OrderStatus status;
// ... other fields
}
public enum OrderStatus {
PENDING, COMPLETED, CANCELLED
}
// OrderService.java
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final StreamBridge streamBridge;
public void createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setStatus(OrderStatus.PENDING);
orderRepository.save(order);
OrderCreatedEvent event = new OrderCreatedEvent(order.getId(), request.items());
streamBridge.send("order-created-out-0", event);
}
// Listener for compensating transaction
@Bean
public Consumer<PaymentFailedEvent> handlePaymentFailure() {
return event -> {
orderRepository.findById(event.orderId()).ifPresent(order -> {
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
log.info("Order {} cancelled due to payment failure", event.orderId());
});
};
}
}
// Event record
public record OrderCreatedEvent(UUID orderId, List<Item> items) {}
Step 2: Payment Service
This service listens for the OrderCreated event and handles the payment logic.
// PaymentService.java
@Service
@RequiredArgsConstructor
public class PaymentService {
private final StreamBridge streamBridge;
@Bean
public Consumer<OrderCreatedEvent> handleOrderCreated() {
return event -> {
// Simulate payment processing
boolean paymentSuccess = processPayment(event);
if (paymentSuccess) {
streamBridge.send("payment-processed-out-0", new PaymentProcessedEvent(event.orderId()));
} else {
streamBridge.send("payment-failed-out-0", new PaymentFailedEvent(event.orderId(), "Insufficient funds"));
}
};
}
}
// Event records
public record PaymentProcessedEvent(UUID orderId) {}
public record PaymentFailedEvent(UUID orderId, String reason) {}
Step 3: Stock Service
Finally, the Stock Service listens for successful payments to update its inventory.
// StockService.java
@Service
@RequiredArgsConstructor
public class StockService {
private final StreamBridge streamBridge;
@Bean
public Consumer<PaymentProcessedEvent> handlePaymentProcessed() {
return event -> {
// Update stock logic here...
log.info("Updating stock for order {}", event.orderId());
// Notify that stock has been updated
streamBridge.send("stock-updated-out-0", new StockUpdatedEvent(event.orderId()));
};
}
}
// Event record
public record StockUpdatedEvent(UUID orderId) {}
In a real-world scenario, the Order Service would also listen for the StockUpdatedEvent to finally mark the order as COMPLETED.
Implementation Deep Dive: An Orchestration-Based Saga
Now, let's refactor our e-commerce example to use the Orchestration pattern. A central SagaOrchestrator will manage the workflow, telling each service what to do. For this example, we'll imagine the services communicate via a message queue where the orchestrator sends commands and receives replies.
The New Flow with an Orchestrator
- The
Order Servicereceives a request, creates aPENDINGorder, and tells theSagaOrchestratorto start a new "Create Order" saga. - The Orchestrator sends a
ProcessPaymentcommand. - The
Payment Servicereceives the command, performs the payment, and sends aPaymentProcessedreply. - The Orchestrator receives the reply and sends an
UpdateStockcommand. - The
Stock Servicereceives the command, updates its inventory, and sends aStockUpdatedreply. - The Orchestrator receives the reply and sends a final
CompleteOrdercommand to theOrder Service.
Step 1: The Saga Orchestrator
This is the brain of our operation. It maintains the state of the saga and executes the steps. We can model the saga steps using a state machine.
// SagaOrchestrator.java
@Service
@RequiredArgsConstructor
public class SagaOrchestrator {
private final StreamBridge streamBridge;
// 1. Starts the saga
public void startCreateOrderSaga(Order order) {
// In a real app, you'd persist the saga state
log.info("Starting saga for order {}", order.getId());
ProcessPaymentCommand command = new ProcessPaymentCommand(order.getId(), order.getTotalAmount());
streamBridge.send("process-payment-out-0", command);
}
// 2. Handles successful payment
@Bean
public Consumer<PaymentProcessedEvent> handlePaymentSuccess() {
return event -> {
log.info("Payment successful for order {}. Updating stock.", event.orderId());
// Fetch saga state to get item list
UpdateStockCommand command = new UpdateStockCommand(event.orderId(), ...); // Assuming items are retrieved from saga state
streamBridge.send("update-stock-out-0", command);
};
}
// 3. Handles successful stock update
@Bean
public Consumer<StockUpdatedEvent> handleStockUpdateSuccess() {
return event -> {
log.info("Stock updated for order {}. Completing order.", event.orderId());
CompleteOrderCommand command = new CompleteOrderCommand(event.orderId());
streamBridge.send("complete-order-out-0", command);
};
}
// 4. Handles payment failure (Compensation)
@Bean
public Consumer<PaymentFailedEvent> handlePaymentFailure() {
return event -> {
log.error("Payment failed for order {}. No compensation needed yet.", event.orderId());
// Here, the first step failed, so we'd just mark the saga as failed
// and notify the OrderService to cancel the order.
CancelOrderCommand command = new CancelOrderCommand(event.orderId());
streamBridge.send("cancel-order-out-0", command);
};
}
// Example of a later-stage failure (Compensation)
@Bean
public Consumer<StockUpdateFailedEvent> handleStockUpdateFailure() {
return event -> {
log.error("Stock update failed for order {}. Starting compensation.", event.orderId());
// Stock update failed, so we need to compensate for the payment.
RefundPaymentCommand command = new RefundPaymentCommand(event.orderId());
streamBridge.send("refund-payment-out-0", command);
};
}
}
Step 2: Modifying Participant Services
The participant services become simpler. They just need to listen for direct commands and reply with event messages.
// PaymentService.java - Now listens for a command
@Service
@RequiredArgsConstructor
public class PaymentService {
private final StreamBridge streamBridge;
@Bean
public Consumer<ProcessPaymentCommand> handleProcessPayment() {
return command -> {
if (processPayment(command.amount())) {
streamBridge.send("payment-processed-out-0", new PaymentProcessedEvent(command.orderId()));
} else {
streamBridge.send("payment-failed-out-0", new PaymentFailedEvent(command.orderId(), "Insufficient Funds"));
}
};
}
@Bean
public Consumer<RefundPaymentCommand> handleRefundPayment() {
return command -> {
log.info("Refunding payment for order {}", command.orderId());
// ... refund logic
};
}
}
// StockService.java - Now listens for a command
@Service
@RequiredArgsConstructor
public class StockService {
private final StreamBridge streamBridge;
@Bean
public Consumer<UpdateStockCommand> handleUpdateStock() {
return command -> {
if (updateStock(command.items())) {
streamBridge.send("stock-updated-out-0", new StockUpdatedEvent(command.orderId()));
} else {
streamBridge.send("stock-update-failed-out-0", new StockUpdateFailedEvent(command.orderId(), "Item out of stock"));
}
};
}
}
Choreography vs. Orchestration: Which One to Choose?
Your choice depends on the complexity of your workflow.
| Feature | Choreography | Orchestration |
|---|---|---|
| Coupling | Loose coupling. Services don't need to know about each other. | Tighter coupling. Services are coupled to the orchestrator. |
| Complexity | Simple for a few participants. Gets complex to track as services grow. | Centralized logic is easier to manage and debug for complex flows. |
| Point of Failure | No single point of failure. | The orchestrator can become a single point of failure. |
| Best For | Simple, linear workflows with a small number of services. | Complex workflows with many services, branching, or retries. |
Conclusion
The Saga pattern is more than just a design pattern; it's a fundamental shift in how we approach data consistency in a distributed world. By breaking down global transactions into a sequence of manageable, local ones, we can build systems that are both resilient and scalable.
In this guide, we've explored the two primary Saga strategies:
- Choreography: A decentralized, event-driven approach that offers great loose coupling and is simple for straightforward workflows.
- Orchestration: A centralized model that provides better control, visibility, and error handling for complex, multi-step processes.
The choice between them isn't about right versus wrong, but about trade-offs. Do you need the simplicity of choreography, or the explicit control of an orchestrator? Understanding this trade-off is key to making the right architectural decision for your project.
Whichever path you choose, mastering Sagas is a crucial step toward building robust, modern, and reliable microservice applications.
Happy coding!