Hook: Think of a restaurant order where dessert fails after dinner is already served: you do not erase the whole meal, you undo only the parts that can still be undone.
Question: What is the Saga Pattern in Spring Boot microservices?
Answer: The Saga Pattern is a way to complete one business process across multiple microservices without one giant distributed transaction. Each service commits a local transaction in its own database, then the next step runs; if a later step fails, earlier steps are reversed using compensating transactions like refunds or stock releases. In Spring Boot, you usually implement it with an orchestrator service or with events over a broker such as Kafka or RabbitMQ.
Interview-Ready Answer: I use the Saga Pattern when one business action touches multiple microservices and I cannot rely on one ACID transaction. I let each service commit locally, then coordinate the next step, and if something fails I run compensating actions in reverse order, like refunding payment and releasing inventory. The key trade-off is eventual consistency instead of a single global lock, so I make every step idempotent and add retries plus timeouts.
Saga is not a special Spring feature; it is an architectural pattern for distributed business transactions. A local transaction means one service updates only its own database and commits immediately. A compensating transaction is an undo step at the business level, not a magical database rollback. For example, if payment was captured, the compensation is a refund; if inventory was reserved, the compensation is a release.
PENDING state and saves a saga identifier, often called a correlation ID or saga ID. This ID lets every service talk about the same request.idempotent, meaning repeating it gives the same safe result. This protects you from duplicate messages and retries.COMPLETED, FAILED, or COMPENSATING in a durable store so the system can recover after a crash.Use Saga when a single user action spans several services, such as checkout, booking, onboarding, or funds transfer. Use it when the business can tolerate eventual consistency, which means data is temporarily inconsistent but converges soon after. Do not use it when you need one atomic database commit across everything, or when side effects cannot be undone in a business-safe way, such as sending a physical parcel without a cancel option.
| Approach | How it works | Pros | Cons |
|---|---|---|---|
| Saga | Local commits + compensation | Scales well, no global lock | Eventual consistency, complex undo |
| 2PC | Prepare then commit everywhere | Strong consistency | Blocking, slower, poor availability |
| Single DB tx | One database commit | Simple and safe | Not cross-service |
In microservices, Saga is usually preferred over 2PC because two-phase commit keeps resources locked while every participant votes. That hurts latency and can reduce availability if one participant is slow or down. Saga avoids that lock, but you pay with more application logic, more monitoring, and eventual consistency.
Saga adds network hops. A simple checkout might need 3 to 6 service calls, and a failure path may add 2 or 3 more compensation calls. In practice, teams often use bounded retries such as 3 attempts with exponential backoff like 100 ms, 500 ms, and 2 s, plus a timeout budget of 5 to 30 s for synchronous orchestration, or minutes for fully asynchronous flows. Complexity is O(n) for both forward steps and compensation, where n is the number of steps.
Memory-level rule: Saga is not “rollback the world”; it is “commit locally, then clean up in reverse if needed.”
Imagine a flash-sale checkout service in an e-commerce platform. One order touches Order Service, Payment Service, Inventory Service, and maybe Shipping Service. The saga lets checkout stay responsive: create the order, authorize payment, reserve stock, and then confirm. If stock is gone, the saga refunds the payment and cancels the order instead of leaving the customer in a half-complete state.
What goes wrong in production: a team forgets to make the refund step idempotent. A timeout causes the orchestrator to retry, the payment gateway receives the same refund twice, and the customer gets double credit. Symptoms include repeated logs with the same saga ID, orders stuck in COMPENSATING, support tickets about odd balances, and a metrics spike in refund failures or duplicate events. The business impact is serious because the system looks “mostly working” while money is leaking in the background.
package com.example.saga;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@SpringBootApplication
public class SagaPatternApplication {
public static void main(String[] args) {
// We use a non-web app so the demo runs once and exits cleanly.
SpringApplication app = new SpringApplication(SagaPatternApplication.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
@Bean
CommandLineRunner demo(OrderSagaOrchestrator saga) {
return args -> {
saga.executeSaga("ORD-1001", "BOOK-1", 2, new BigDecimal("49.99"));
saga.executeSaga("ORD-1002", "LAPTOP-FAIL", 1, new BigDecimal("199.99"));
// Edge case: duplicate order ID. A real saga must be safe when the same request is retried.
try {
saga.executeSaga("ORD-1001", "BOOK-1", 2, new BigDecimal("49.99"));
} catch (Exception ex) {
System.out.println("Duplicate request rejected: " + ex.getMessage());
}
};
}
@Bean
OrderService orderService() {
return new OrderService();
}
@Bean
PaymentService paymentService() {
return new PaymentService();
}
@Bean
InventoryService inventoryService() {
return new InventoryService();
}
@Bean
OrderSagaOrchestrator sagaOrchestrator(OrderService orderService,
PaymentService paymentService,
InventoryService inventoryService) {
return new OrderSagaOrchestrator(orderService, paymentService, inventoryService);
}
static class OrderSagaOrchestrator {
private final OrderService orderService;
private final PaymentService paymentService;
private final InventoryService inventoryService;
OrderSagaOrchestrator(OrderService orderService,
PaymentService paymentService,
InventoryService inventoryService) {
this.orderService = orderService;
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
public void executeSaga(String orderId, String sku, int quantity, BigDecimal amount) {
System.out.println("\n=== Starting saga for " + orderId + " ===");
boolean orderCreated = false;
boolean paymentCharged = false;
boolean inventoryReserved = false;
try {
// Step 1: create the order locally. This is not a global transaction.
orderService.createOrder(orderId, sku, quantity, amount);
orderCreated = true;
// Step 2: charge payment. If this fails, no inventory was reserved yet.
paymentService.charge(orderId, amount);
paymentCharged = true;
// Step 3: reserve stock. This demo fails for SKU ending in FAIL.
inventoryReserved = inventoryService.reserve(orderId, sku, quantity);
// Final step: only confirm if every previous step worked.
orderService.confirm(orderId);
System.out.println("Saga completed successfully for " + orderId);
} catch (RuntimeException ex) {
System.out.println("Saga failed for " + orderId + ": " + ex.getMessage());
// Compensate in reverse order. Undo the most recent success first.
if (inventoryReserved) {
inventoryService.release(orderId, sku, quantity);
}
if (paymentCharged) {
paymentService.refund(orderId, amount);
}
if (orderCreated) {
orderService.cancel(orderId);
}
System.out.println("Compensation finished for " + orderId);
}
System.out.println("Final order state: " + orderService.status(orderId));
System.out.println("\n");
}
}
static class OrderService {
private final Map<String, OrderStatus> orders = new ConcurrentHashMap<>();
public void createOrder(String orderId, String sku, int quantity, BigDecimal amount) {
OrderStatus previous = orders.putIfAbsent(orderId, OrderStatus.PENDING);
if (previous != null) {
throw new IllegalStateException("Order already exists: " + orderId);
}
System.out.println("Order created: " + orderId + " -> PENDING");
}
public void confirm(String orderId) {
OrderStatus current = require(orderId);
if (current != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot confirm order in state: " + current);
}
orders.put(orderId, OrderStatus.CONFIRMED);
System.out.println("Order confirmed: " + orderId);
}
public void cancel(String orderId) {
OrderStatus current = require(orderId);
if (current == OrderStatus.CONFIRMED) {
throw new IllegalStateException("Cannot cancel a confirmed order: " + orderId);
}
orders.put(orderId, OrderStatus.CANCELLED);
System.out.println("Order cancelled: " + orderId);
}
public OrderStatus status(String orderId) {
return orders.get(orderId);
}
private OrderStatus require(String orderId) {
OrderStatus status = orders.get(orderId);
if (status == null) {
throw new IllegalStateException("Unknown order: " + orderId);
}
return status;
}
}
static class PaymentService {
private final Map<String, BigDecimal> charges = new ConcurrentHashMap<>();
private final Map<String, BigDecimal> refunds = new ConcurrentHashMap<>();
public void charge(String orderId, BigDecimal amount) {
// This simulates a business failure that happens after the order is created.
if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new IllegalStateException("Card declined for high amount: " + amount);
}
charges.putIfAbsent(orderId, amount);
System.out.println("Payment charged: " + orderId + " amount=" + amount);
}
public void refund(String orderId, BigDecimal amount) {
// Idempotency matters: if retry happens, we do not want to refund twice.
if (refunds.containsKey(orderId)) {
System.out.println("Refund already processed for " + orderId + ", skipping duplicate");
return;
}
if (!charges.containsKey(orderId)) {
System.out.println("No charge found for " + orderId + ", nothing to refund");
return;
}
refunds.put(orderId, amount);
System.out.println("Payment refunded: " + orderId + " amount=" + amount);
}
}
static class InventoryService {
private final Map<String, Integer> stock = new ConcurrentHashMap<>(Map.of(
"BOOK-1", 10,
"PHONE-1", 5
));
private final Map<String, Integer> reservations = new ConcurrentHashMap<>();
public boolean reserve(String orderId, String sku, int quantity) {
if (sku.endsWith("FAIL")) {
throw new IllegalStateException("Inventory lookup failed for SKU: " + sku);
}
if (reservations.containsKey(orderId)) {
System.out.println("Inventory already reserved for " + orderId + ", skipping duplicate");
return true;
}
int available = stock.getOrDefault(sku, 0);
if (available < quantity) {
throw new IllegalStateException("Not enough stock for " + sku + ", requested=" + quantity + ", available=" + available);
}
stock.put(sku, available - quantity);
reservations.put(orderId, quantity);
System.out.println("Inventory reserved: " + orderId + " sku=" + sku + " qty=" + quantity);
return true;
}
public void release(String orderId, String sku, int quantity) {
Integer reserved = reservations.remove(orderId);
if (reserved == null) {
System.out.println("No reservation found for " + orderId + ", nothing to release");
return;
}
stock.put(sku, stock.getOrDefault(sku, 0) + quantity);
System.out.println("Inventory released: " + orderId + " sku=" + sku + " qty=" + quantity);
}
}
enum OrderStatus {
PENDING,
CONFIRMED,
CANCELLED
}
}
Follow-up & Tricky Questions:
Common Mistakes:
Memory Hook: “Commit locally, clean up in reverse.” That single line captures the whole pattern.
Cheat Sheet:
Practice Tasks: