Saga Pattern in Spring Boot: Coordinating Orders and Inventory Without Two-Phase Commit
When an order gets created in a microservices system, more than one service almost always has to be touched: the orders service registers the order, and the inventory service has to reserve the matching stock. If both lived in the same database, this would be an ordinary transaction with a commit or a rollback. The moment they are two services with their own databases, there is no longer an ACID transaction covering both steps at once, and the question to answer before writing a single line of code is what happens when the first step succeeds and the second one fails.
Why two-phase commit is not the answer
The first idea that usually comes up is stretching the transaction across both services with a two-phase commit (2PC) coordinator: each participant prepares its change and waits for confirmation before applying it. On paper it solves the problem. In practice, 2PC requires every participant to keep its resources locked while it waits for the coordinator's confirmation, which in a microservices system means holding locks on the inventory database for as long as the round trip to the orders service takes. If the orders service goes down between prepare and commit, inventory stays locked indefinitely. On top of that, most of the message brokers and managed databases we use with Spring Boot either do not expose full XA support, or expose it in a way that complicates the connection pool without delivering the guarantee it promises. The runtime coupling that 2PC demands — every participant available and responding at the same time — is exactly what splitting the services was meant to avoid.
The saga pattern: local steps with their own compensation
A saga replaces the distributed transaction with a sequence of local transactions, each one committed immediately in its own service, and attaches a compensating action to every step that undoes its effect if a later step in the sequence fails. Nothing blocks waiting on someone else's confirmation: each step commits on its own, and if something fails further down the line, the saga runs the compensations for the steps already confirmed, in reverse order.
For orders and inventory, the sequence looks like this: the order is created first in a PENDING state, then stock gets reserved in inventory, and only once the reservation is confirmed does the order get marked CONFIRMED. If the inventory reservation fails because there is not enough stock, the compensation is cancelling the order that was already created. Nothing needs to be undone in inventory, because that step never got committed in the first place.
We went with an orchestrated saga instead of one choreographed purely through events, because we wanted the state of any saga in progress to be queryable in one place, not reconstructed from the event history spread across several topics. An explicit orchestrator keeps that responsibility inside a single class:
@Service
public class CreateOrderSagaOrchestrator {
private final OrderRepository orderRepository;
private final InventoryClient inventoryClient;
private final SagaLogRepository sagaLogRepository;
@Transactional
public Order execute(CreateOrderCommand command) {
Order order = orderRepository.save(
Order.pending(command.customerId(), command.items()));
sagaLogRepository.recordStep(order.getId(), "ORDER_CREATED");
try {
ReserveInventoryResponse reservation =
inventoryClient.reserve(order.getId(), command.items());
order.confirm(reservation.reservationId());
sagaLogRepository.recordStep(order.getId(), "INVENTORY_RESERVED");
} catch (InsufficientStockException | InventoryUnavailableException ex) {
compensateOrderCreation(order);
throw new SagaFailedException("Could not reserve inventory for order "
+ order.getId(), ex);
}
return orderRepository.save(order);
}
private void compensateOrderCreation(Order order) {
order.cancel();
orderRepository.save(order);
sagaLogRepository.recordStep(order.getId(), "ORDER_CANCELLED_COMPENSATION");
}
}
Each step gets recorded in sagaLogRepository before the next one is attempted, not after. That matters for the case where the process itself crashes between two steps: on restart, a reconciliation job can read the log, find sagas whose last recorded step is ORDER_CREATED with neither INVENTORY_RESERVED nor ORDER_CANCELLED_COMPENSATION after a reasonable amount of time, and force the pending compensation instead of leaving the order stuck in PENDING forever.
Idempotency in the inventory reservation
The HTTP client toward the inventory service needs retries, because a call that fails with a timeout does not say whether the reservation was applied on the server side before the response got lost on the network. Without an idempotency key, an automatic retry can end up reserving the same stock twice for the same order.
@PostMapping("/reservations")
public ResponseEntity<ReserveInventoryResponse> reserve(
@RequestBody ReserveInventoryRequest request) {
Optional<Reservation> existing = reservationRepository
.findByIdempotencyKey(request.orderId().toString());
if (existing.isPresent()) {
return ResponseEntity.ok(ReserveInventoryResponse.from(existing.get()));
}
Reservation reservation = inventoryService.reserveStock(
request.orderId(), request.items());
return ResponseEntity.ok(ReserveInventoryResponse.from(reservation));
}
The idempotency key is the order's own identifier, because within this saga every order attempts to reserve inventory exactly once by the flow's own definition. If the retry arrives after the first call actually went through, the inventory service returns the existing reservation instead of deducting stock a second time, and the orchestrator gets back a successful response without being able to tell whether it was the first call or a retry — which is exactly the behavior it needs.
What did not work: pure event-based choreography
The first version we built had no orchestrator: the orders service published an OrderCreated event, the inventory service consumed it and published either InventoryReserved or InsufficientStock, and the orders service listened for those events to decide whether to confirm or cancel. It worked in the happy path, but debugging an order that got stuck turned slow as soon as the number of steps grew past two: figuring out where in the saga a specific order stood meant going through the logs of two different services and reconstructing the event order by hand, because there was no single place to query that saga's current state. An event dropped by a consumer that happened to be down at the wrong moment left an order sitting in PENDING with no service clearly responsible for noticing, because each one only knew how to react to the events that reached it, not to detect the ones that never arrived. Moving to an explicit orchestrator with its own step log did not change the fact that communication between services is still asynchronous, but it did concentrate the responsibility for knowing each saga's state into a single class backed by a single table, instead of spreading it implicitly across each service's event handlers.
When this is worth it
A saga with an orchestrator, a step log, and compensations is more code than a plain synchronous REST call, and that cost only pays off when the business operation actually crosses two or more services with their own databases and needs to stay consistent across them. If a single service can resolve the whole operation inside its own local transaction, layering a saga on top protects nothing that was not already protected, and only adds surface area to maintain. The question we use to decide is whether the operation can fail halfway in a way that is visible to the user or to another downstream system; if the answer is yes, an explicit compensation is needed, and a saga is the clearest way we have found to make that visible in the code instead of leaving it implicit in the hope that nothing fails between two network calls.