Definition
- @Transactional limitation – wraps one database connection in one ACID transaction; if it throws, only that one database rolls back, not commits in other services.
- Saga pattern – models a multi-service operation as a sequence of local transactions, each committed in its own service, with a compensating transaction defined for each forward step.
- Compensating transaction – a semantic undo that reverses the effect of a committed step (release reserved stock, cancel order), not a literal database rollback.
- Orchestration – a central coordinator owns the Saga state machine, invokes each step, and drives compensations on failure.
- Choreography – each service reacts to events from the previous service with no central coordinator.
Imagine Rajesh orders a product through National Bank of India's commerce platform. An order is created, stock is reserved, and then payment fails. These steps happened in three different departments with three different ledgers, so there is no single undo button. Instead, each department that already acted posts a correcting entry: the inventory department releases the reserved stock, the order department cancels the order. The system walks backward with explicit undo steps until everything is consistent again.
Understanding the Concepts
Why is @Transactional not enough for microservices?
A Spring @Transactional method wraps a single database connection in one ACID transaction. If it throws, that database rolls back. But in a microservices flow, the Order Service committed to its own database, the Inventory Service reserved stock in its own database, and the Payment Service runs against a third. When Payment fails, your local rollback cannot reach the other services.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class MonolithicOrderService {
// In a monolith, this works: one transaction, one database
@Transactional
public void placeOrder(String customerId, String productId,
BigDecimal amount) {
orderRepository.createOrder(customerId, productId); // same DB
inventoryRepository.reserveStock(productId); // same DB
paymentRepository.chargePayment(customerId, amount); // same DB
// If payment fails → everything rolls back automatically
}
}
package com.bank.service;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class MicroserviceOrderService {
// In microservices, this DOES NOT work!
public void placeOrder(String customerId, String productId,
BigDecimal amount) {
orderClient.createOrder(customerId, productId); // Service A, DB A
inventoryClient.reserveStock(productId); // Service B, DB B
paymentClient.charge(customerId, amount); // Service C, DB C
// If payment fails → order and inventory are ALREADY COMMITTED
// @Transactional cannot reach DB A and DB B to roll them back
}
}
// Monolith: one @Transactional → one rollback → all or nothing // Microservices: three services, three databases, three separate commits // Payment failure → order committed, stock reserved, no way to auto-rollback
What is the Saga pattern?
A Saga models the operation as an ordered series of local transactions T1, T2, T3, each committed immediately in its own service. For each forward step with a side effect, you define a compensating transaction C that semantically reverses it.
package com.bank.saga;
import java.math.BigDecimal;
public class OrderSagaDefinition {
// Forward steps and their compensations:
// T1: Create Order → C1: Cancel Order
// T2: Reserve Stock → C2: Release Stock
// T3: Charge Payment → C3: Refund Payment
// T4: Schedule Delivery → (non-compensatable, runs last)
// If T3 (Payment) fails:
// Run C2: Release Stock
// Run C1: Cancel Order
// Do NOT run T4: Delivery never starts
}
How does the orchestrator drive the Saga?
An orchestrator owns the Saga state machine. It invokes each step, records the state, and on failure drives compensations in reverse order.
package com.bank.saga;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class OrderSagaOrchestrator {
private final OrderClient orderClient;
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
private final DeliveryClient deliveryClient;
private final SagaStateRepository sagaRepo;
public OrderSagaOrchestrator(OrderClient orderClient,
InventoryClient inventoryClient,
PaymentClient paymentClient,
DeliveryClient deliveryClient,
SagaStateRepository sagaRepo) {
this.orderClient = orderClient;
this.inventoryClient = inventoryClient;
this.paymentClient = paymentClient;
this.deliveryClient = deliveryClient;
this.sagaRepo = sagaRepo;
}
public String executeSaga(String customerId, String productId,
BigDecimal amount) {
String sagaId = "SAGA-" + System.currentTimeMillis();
sagaRepo.save(sagaId, "STARTED");
try {
// T1: Create Order
String orderId = orderClient.createOrder(customerId, productId);
sagaRepo.save(sagaId, "ORDER_CREATED");
// T2: Reserve Stock
inventoryClient.reserveStock(productId, orderId);
sagaRepo.save(sagaId, "STOCK_RESERVED");
// T3: Charge Payment
paymentClient.charge(customerId, amount, orderId);
sagaRepo.save(sagaId, "PAYMENT_CHARGED");
// T4: Schedule Delivery (non-compensatable, runs last)
deliveryClient.schedule(orderId);
sagaRepo.save(sagaId, "COMPLETED");
return orderId;
} catch (PaymentFailedException e) {
// Payment failed: compensate in reverse order
sagaRepo.save(sagaId, "COMPENSATING");
inventoryClient.releaseStock(productId); // C2
orderClient.cancelOrder(customerId); // C1
sagaRepo.save(sagaId, "COMPENSATED");
throw new OrderFailedException(
"Payment failed, order compensated: " + e.getMessage());
}
}
}
// Happy path: T1 → T2 → T3 → T4 → COMPLETED // Payment fails: T1 → T2 → T3(fail) → C2 → C1 → COMPENSATED // End state: order CANCELLED, stock released, no money charged
Why must compensations be idempotent?
The Saga may retry a compensation after a transient failure. Releasing stock twice or refunding twice must be safe.
package com.bank.service;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
private final InventoryRepository inventoryRepo;
public InventoryService(InventoryRepository inventoryRepo) {
this.inventoryRepo = inventoryRepo;
}
// Idempotent compensation: safe to call multiple times
public void releaseStock(String productId, String orderId) {
var reservation = inventoryRepo.findByOrderId(orderId);
if (reservation == null || reservation.isReleased()) {
return; // Already released or never reserved, no-op
}
reservation.release();
inventoryRepo.save(reservation);
}
}
// First call: reservation exists, not released → release it // Second call: reservation exists, already released → no-op // No reservation: orderId not found → no-op // Safe to retry without double-releasing
Why must non-compensatable steps run last?
Some steps cannot be undone. Once you physically ship an item, you cannot un-ship it. That is why delivery scheduling always comes after payment succeeds.
// Step ordering:
// T1: Create Order (compensatable: cancel)
// T2: Reserve Stock (compensatable: release)
// T3: Charge Payment (compensatable: refund) ← PIVOT POINT
// T4: Schedule Delivery (non-compensatable: cannot un-ship)
// T4 only runs after T3 succeeds
// If T3 fails → compensate T2 and T1 → delivery never starts
// If T4 fails after T3 succeeds → forward recovery (retry delivery)
How does choreography compare to orchestration for Sagas?
| Aspect | Orchestration | Choreography |
|---|---|---|
| Coordinator | Central orchestrator | None (event-driven) |
| Visibility | State machine shows exact state | Must trace events across services |
| Compensation | Orchestrator drives in order | Each service handles failure events |
| Coupling | Services coupled to orchestrator | Services coupled to event types |
| Complexity | One place for flow logic | Flow scattered across services |
| Best for | Complex flows with compensations | Simple, independent fan-outs |
// Choreography version (no coordinator):
// Order Service: creates order → publishes OrderCreated event
// Inventory Service: hears OrderCreated → reserves stock → publishes StockReserved
// Payment Service: hears StockReserved → charges → publishes PaymentCharged
// If Payment fails → publishes PaymentFailed
// Inventory Service: hears PaymentFailed → releases stock
// Order Service: hears PaymentFailed → cancels order
// Problem: who tracks the state? Nobody.
// Problem: compensation order is implicit and fragile
What are common mistakes? (wrong vs correct)
The most common mistake is trying to use two-phase commit across REST services.
// WRONG: trying distributed 2PC across REST services
// 1. Begin distributed transaction
// 2. orderService.prepare() → "PREPARED"
// 3. inventoryService.prepare() → "PREPARED"
// 4. paymentService.prepare() → timeout!
// 5. Coordinator stuck: rollback the others? What if they already committed?
// 2PC across REST is blocking, slow, and usually not supported
// CORRECT: Saga with compensating transactions
// 1. T1: createOrder() → committed
// 2. T2: reserveStock() → committed
// 3. T3: chargePayment() → FAILED
// 4. C2: releaseStock() → compensated
// 5. C1: cancelOrder() → compensated
// Eventually consistent, no blocking, no coordinator lock
What should a senior engineer know?
A senior engineer uses the transactional outbox to ensure each step and its event are written atomically. They keep explicit Saga state for crash recovery. They prefer orchestration for complex flows with compensations because it is observable and testable as a unit.
// Senior checklist for production Sagas:
// 1. Each step: local commit + outbox entry in same transaction
// 2. Compensations: idempotent and retryable
// 3. Non-compensatable steps: ordered last (after pivot point)
// 4. Saga state: persisted for crash recovery and observability
// 5. Orchestrator: state machine (Spring State Machine / Temporal)
// 6. Timeouts: each step has a timeout, expired steps trigger compensation
Visual Overview
Saga: Happy Path
────────────────
Orchestrator Order Svc Inventory Svc Payment Svc Delivery Svc
│ │ │ │ │
│── T1 ────────▶│ │ │ │
│◀── orderId ──│ │ │ │
│── T2 ─────────────────────▶│ │ │
│◀── reserved ──────────────│ │ │
│── T3 ──────────────────────────────────▶ │ │
│◀── charged ───────────────────────────── │ │
│── T4 ─────────────────────────────────────────────────▶ │
│◀── scheduled ────────────────────────────────────────── │
│ State: COMPLETED │
Saga: Payment Fails → Compensate
─────────────────────────────────
Orchestrator Order Svc Inventory Svc Payment Svc
│ │ │ │
│── T1 ────────▶│ │ │
│◀── orderId ──│ │ │
│── T2 ─────────────────────▶│ │
│◀── reserved ──────────────│ │
│── T3 ──────────────────────────────────▶ │
│◀── FAILED ────────────────────────────── │
│ │
│── C2: release ────────────▶│ │
│◀── released ──────────────│ │
│── C1: cancel ────▶│ │
│◀── cancelled ────│ │
│ State: COMPENSATED │
│ End: order cancelled, stock released, no charge
Key Takeaway
In a Search, Order, Payment, and Delivery flow, @Transactional cannot keep things consistent because it wraps only one database connection. When the Order Service commits to its own database and the Inventory Service reserves stock in its own database, those commits have already happened in separate processes. When Payment then fails, your local rollback cannot reach them. The Saga pattern handles this by modeling the operation as a sequence of local transactions, each committed immediately in its own service, with a compensating transaction defined for each step that can be undone.
At National Bank of India, when Rajesh placed an order and payment was declined, the orchestrator ran compensations in reverse: the Inventory Service released the two reserved units, and the Order Service cancelled the order. The end state was perfectly consistent with nothing charged, even though no single transaction ever spanned the services. Compensations are semantic, not literal: you release stock rather than rolling back, you refund rather than un-charging, and every compensation is idempotent so it can be safely retried.
Pankaj prefers orchestration over choreography for complex flows because a central orchestrator owns the Saga state machine, explicitly drives each step, and issues compensations from one observable place. Non-compensatable steps like physical delivery are ordered last so they only run after payment succeeds. Each step and its triggering event are written through the transactional outbox so they never drift apart.
Remember one sentence: when @Transactional cannot cross service boundaries, use a Saga that commits each step locally and, on failure, runs idempotent compensating transactions in reverse to reach a clean consistent end state.
---