1. Overview
The Saga Pattern is a microservice architectural pattern for managing data consistency across multiple services in distributed transaction scenarios. Instead of using distributed transactions (which are difficult to scale), Saga breaks a transaction into a series of local transactions, where each service performs its operation and publishes an event to trigger the next step. If a failure occurs, a series of compensating transactions are invoked to undo the previous operations.
In this tutorial, we will implement the Saga Pattern using Apache Kafka and Spring Boot 3 with four microservices:
- Order Service
- Payment Service
- Inventory Service
- Shipping Service
Kafka will act as the event bus for coordination between the services.
Ways to implement Saga
1. Orchestration (Command-Based Coordination)
In this approach, a central coordinator (orchestrator) controls the Saga flow. It sends commands to services and decides what should happen next. The orchestrator knows the order of steps and handles failure recovery.
Example Tools: Temporal, Axon Framework
Pros:
- Centralized control and visibility.
- Easier to implement complex workflows.
Cons:
- Tight coupling with the orchestrator.
- Becomes a single point of coordination logic.
2. Choreography (Event-Driven Coordination)
In this approach, there is no central coordinator. Each service performs its task and publishes an event. Other services listen for these events and react accordingly. The flow is determined by event chains.
Used in this Tutorial: Kafka-based implementation follows the Choreography approach.
Pros:
- Fully decoupled services.
- More scalable and resilient.
Cons:
- Harder to visualize and manage the flow.
- Debugging and tracing across services can be complex.
2. Architecture Diagram
[Client] --> [Order Service] --(OrderCreatedEvent)--> [Kafka Topic: order-events] <--(OrderFailedEvent)<-- [Kafka Topic: order-failed-events] | (next) --> [Payment Service] --(PaymentCompletedEvent)--> [Kafka Topic: payment-events] | (next) --> [Inventory Service] --(InventoryReservedEvent)--> [Kafka Topic: inventory-events] | (next) --> [Shipping Service] --(ShippedEvent)--> [Kafka Topic: shipping-events]
3. Implementation
Step 1: Common Event Definitions (in a shared library)
Define common events used across services.
public class OrderCreatedEvent { private String orderId; private double amount; // getters and setters } public class PaymentCompletedEvent { private String orderId; // getters and setters } public class InventoryReservedEvent { private String orderId; // getters and setters } public class OrderFailedEvent { private String orderId; private String reason; // getters and setters } public class ShippedEvent { private String orderId; // getters and setters }
Explanation: These classes represent the messages passed between services. Each event carries necessary information like orderId
, amount
, or failure reason that downstream services will consume.
Step 2: Order Service
Handles order creation and starts the Saga by publishing an OrderCreatedEvent
.
OrderController.java
@PostMapping("/orders") public ResponseEntity<String> createOrder(@RequestBody OrderRequest request) { // Save order to DB with status CREATED OrderCreatedEvent event = new OrderCreatedEvent(orderId, request.getAmount()); kafkaTemplate.send("order-events", event); return ResponseEntity.ok("Order placed"); }
Explanation: When a user places an order, the service persists the initial state and sends OrderCreatedEvent
to the Kafka topic. This kicks off the Saga process.
KafkaListener for failures
@KafkaListener(topics = "order-failed-events") public void handleFailure(OrderFailedEvent event) { // Update order status to FAILED log.error("Order {} failed due to: {}", event.getOrderId(), event.getReason()); }
Explanation: If any service down the chain fails, the failure event is caught here, and the order status is updated accordingly.
Step 3: Payment Service
Listens for OrderCreatedEvent
and processes payment.
@KafkaListener(topics = "order-events") public void processOrder(OrderCreatedEvent event) { boolean paymentSuccess = paymentService.charge(event.getAmount()); if (paymentSuccess) { kafkaTemplate.send("payment-events", new PaymentCompletedEvent(event.getOrderId())); } else { kafkaTemplate.send("order-failed-events", new OrderFailedEvent(event.getOrderId(), "Payment failed")); } }
Explanation: This service checks if payment can be processed. If successful, it proceeds by emitting a PaymentCompletedEvent
. If it fails, it emits an OrderFailedEvent
to initiate compensating transactions.
Step 4: Inventory Service
Listens for PaymentCompletedEvent
and checks/reserves inventory.
@KafkaListener(topics = "payment-events") public void reserveInventory(PaymentCompletedEvent event) { boolean reserved = inventoryService.reserve(event.getOrderId()); if (reserved) { kafkaTemplate.send("inventory-events", new InventoryReservedEvent(event.getOrderId())); } else { kafkaTemplate.send("order-failed-events", new OrderFailedEvent(event.getOrderId(), "Inventory not available")); } }
Explanation: This service tries to reserve items in the inventory. If successful, the saga continues. Otherwise, it fails the saga and triggers rollback.
Step 5: Shipping Service
Listens for InventoryReservedEvent
and ships the product.
@KafkaListener(topics = "inventory-events") public void shipOrder(InventoryReservedEvent event) { shippingService.ship(event.getOrderId()); kafkaTemplate.send("shipping-events", new ShippedEvent(event.getOrderId())); log.info("Order {} shipped successfully", event.getOrderId()); }
Explanation: The final service ships the product and optionally emits a final ShippedEvent
for logging or further processing.
Compensating Transactions (Failure Handling)
A compensating transaction is an action taken to undo the effects of a previous successful transaction in a distributed workflow, typically when a failure occurs downstream. Instead of rolling back all services (which is hard in microservices), each service must implement logic to reverse its step.
Each service should define a rollback operation if its step fails:
- Payment Service: Refund payment if the order fails after payment.
- Inventory Service: Release reserved stock if order fails after inventory is locked.
- Order Service: Update the order status as failed and notify users.
These compensations should ideally be triggered via listening to OrderFailedEvent
.