Saga Pattern
In a monolith, one @Transactional method keeps orders, payments, and inventory consistent — all or nothing. Split those across services with separate databases and that single ACID boundary is gone. The Saga pattern restores consistency without distributed transactions: a business transaction becomes a sequence of local transactions, each with a compensating action that undoes it if a later step fails.
Why not just a distributed transaction?
Two-phase commit (XA) across services is technically possible but practically avoided: it locks resources across the network, couples services to a coordinator, and scales poorly. The microservices answer is to accept eventual consistency and design for compensation instead.
local tx 1: create order (PENDING)
local tx 2: charge payment
local tx 3: reserve inventory ← fails!
↓ compensate in reverse
comp tx 2: refund payment
comp tx 1: cancel order
A saga has no global rollback — instead each completed step has an explicit compensating transaction that semantically reverses it.
Two flavors: choreography vs orchestration
| Aspect | Choreography | Orchestration |
|---|---|---|
| Coordination | Services react to each other’s events | A central orchestrator issues commands |
| Coupling | Decentralized, event-driven | Centralized control flow |
| Visibility | Flow is implicit across services | Flow is explicit in one place |
| Best for | Simple sagas, few steps | Complex sagas, many steps/branches |
| Risk | Cyclic event chains, hard to trace | Orchestrator becomes a bottleneck |
Choreography saga
Each service publishes events and reacts to others’ events — no central brain. With messaging (see Inter-Service Communication):
// Order service: start the saga
@Service
@RequiredArgsConstructor
public class OrderService {
private final RabbitTemplate rabbit;
@Transactional
public Order place(OrderRequest req) {
Order order = repository.save(Order.pending(req));
rabbit.convertAndSend("saga", "order.created",
new OrderCreated(order.getId(), req.total(), req.items()));
return order;
}
}
// Payment service: react, then emit success or failure
@Component
@RequiredArgsConstructor
public class PaymentSaga {
private final RabbitTemplate rabbit;
@RabbitListener(queues = "payment.order-created")
public void on(OrderCreated event) {
try {
payments.charge(event.orderId(), event.total());
rabbit.convertAndSend("saga", "payment.completed",
new PaymentCompleted(event.orderId()));
} catch (PaymentDeclined ex) {
rabbit.convertAndSend("saga", "payment.failed",
new PaymentFailed(event.orderId()));
}
}
}
// Order service: compensate on failure
@RabbitListener(queues = "order.payment-failed")
public void onPaymentFailed(PaymentFailed event) {
orderRepository.markCancelled(event.orderId()); // compensating action
}
order.created → payment.completed → inventory.reserved → order CONFIRMED
order.created → payment.failed → order CANCELLED (compensate)
Tip: Use a transactional outbox — write the event to an
outboxtable in the same local transaction as the state change, then a relay publishes it. This prevents the classic bug where the DB commits but the event is lost (or vice versa). Spring Modulith and Debezium both implement this.
Orchestration saga
A dedicated orchestrator owns the flow, issuing commands and reacting to replies. The control logic lives in one readable place:
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final PaymentClient payments;
private final InventoryClient inventory;
private final OrderRepository orders;
public void execute(Long orderId, Money total, List<Item> items) {
try {
payments.charge(orderId, total); // step 1
inventory.reserve(orderId, items); // step 2
orders.confirm(orderId); // success
} catch (InventoryUnavailable ex) {
payments.refund(orderId); // compensate step 1
orders.cancel(orderId);
} catch (PaymentDeclined ex) {
orders.cancel(orderId); // nothing to compensate
}
}
}
For non-trivial flows, model the saga as an explicit state machine (e.g. Spring Statemachine) so each state, transition, and compensation is declared rather than buried in try/catch.
Warning: Compensations must be idempotent and tolerant of partial failure — the orchestrator itself can crash mid-saga and resume. Persist saga state after every step so recovery picks up exactly where it left off. See idempotency keys in Inter-Service Communication.
Designing compensations
Not everything can be literally undone. A sent email or a shipped package can’t be “un-sent” — the compensation is a new business action (send an apology, issue a return label).
| Forward action | Compensating action |
|---|---|
| Reserve inventory | Release reservation |
| Charge payment | Refund payment |
| Allocate shipment | Cancel shipment |
| Send confirmation email | Send cancellation email |
Eventual consistency
A saga means the system is temporarily inconsistent — the order exists as PENDING before payment and inventory confirm. Embrace this in the UI (“Order received, confirming…”) and in your data model with explicit status fields. Combined with Distributed Tracing, you can follow a saga end-to-end across services by its trace id.
Note: Sagas are not free. If a workflow genuinely needs strong consistency and the steps share an owner, that’s a strong signal the steps belong in one service (or a modular monolith) — not spread across the network.