Published on

Saga Pattern in Action: A Practical Guide to Distributed Transactions in Spring Boot

Authors
  • avatar
    Name
    Vic Chen
    Twitter

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

  1. Local Transactions: Each service in the Saga performs its own transaction atomically. For example, the Payment Service is only responsible for the payment transaction. It knows nothing about creating orders or updating stock.

  2. Compensating Transactions: This is the crucial part for maintaining consistency. For every operation, you must define a corresponding "undo" operation. For example, if a ProcessPayment action succeeds but the later UpdateStock action fails, a RefundPayment compensating 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

  1. A customer places an order.
  2. The Order Service creates an order in a PENDING state and publishes an OrderCreated event.
  3. The Payment Service listens for this event, processes the payment, and publishes a PaymentProcessed event.
  4. The Stock Service listens for the payment event and updates the stock.
  5. Finally, the Order Service listens for the stock update to mark the order as COMPLETED.

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

  1. The Order Service receives a request, creates a PENDING order, and tells the SagaOrchestrator to start a new "Create Order" saga.
  2. The Orchestrator sends a ProcessPayment command.
  3. The Payment Service receives the command, performs the payment, and sends a PaymentProcessed reply.
  4. The Orchestrator receives the reply and sends an UpdateStock command.
  5. The Stock Service receives the command, updates its inventory, and sends a StockUpdated reply.
  6. The Orchestrator receives the reply and sends a final CompleteOrder command to the Order 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.

FeatureChoreographyOrchestration
CouplingLoose coupling. Services don't need to know about each other.Tighter coupling. Services are coupled to the orchestrator.
ComplexitySimple for a few participants. Gets complex to track as services grow.Centralized logic is easier to manage and debug for complex flows.
Point of FailureNo single point of failure.The orchestrator can become a single point of failure.
Best ForSimple, 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!