Spring Boot Tutorial
Distributed Transactions and the Saga Pattern
In a monolith, placing an order — create the order, reserve stock, charge the payment — is one database transaction: all or nothing. In microservices, each step lives in a different service with its own database, and there is no practical global transaction across them (two-phase commit is slow, fragile and rarely supported by modern infrastructure).
The Saga pattern solves this by breaking the business transaction into a sequence of local transactions, each publishing an event that triggers the next step, with compensating actions that undo earlier steps if a later one fails. This lesson explains choreography and orchestration sagas, the transactional outbox that makes them reliable, idempotency, and how to implement both styles in Spring Boot.
Why Not Distributed Transactions?
Two-phase commit (XA) locks resources in every participant until all agree, so one slow service blocks the others; it also requires every database and broker to support XA. Microservice architectures instead accept eventual consistency: the system may be briefly inconsistent (stock reserved, payment not yet taken) but always converges to a correct final state.
Choreography
In a choreographed saga, services react to each other's events with no central coordinator: Order Service publishes OrderCreated; Inventory reserves stock and publishes StockReserved; Payment charges and publishes PaymentCompleted or PaymentFailed; on failure, Inventory releases the stock and Order marks the order cancelled. It is simple for short flows but hard to follow as steps grow.
Orchestration
In an orchestrated saga, an orchestrator (often inside the Order Service) holds the saga state and tells each participant what to do next, handling failures by issuing compensating commands. The flow is explicit in one place, easier to monitor and change, at the cost of a central component. Frameworks and workflow engines (Temporal, Camunda, Axon) help for complex sagas; simple ones are fine as a state machine persisted in your database.
Making Sagas Reliable
Each step must update its database and publish its event atomically — use the transactional outbox: write the event to an outbox table in the same local transaction, then relay it to the broker. Consumers must be idempotent, because events can be delivered more than once. Compensations must be designed up front: "refund payment", "release stock", "cancel order" — and some actions (sending an email) cannot be undone, so place them at the end.
Examples
Choreography: event flow for placing an order
Happy path
Order : create order (PENDING) ──▶ OrderCreated
Inventory : reserve stock ──▶ StockReserved
Payment : charge card ──▶ PaymentCompleted
Order : mark order CONFIRMED ──▶ OrderConfirmed ──▶ Notification: email
Payment fails
Payment : charge declined ──▶ PaymentFailed
Inventory : release reserved stock (compensation)
Order : mark order CANCELLED (compensation) ──▶ OrderCancelled ──▶ Notification
(Each arrow is a local transaction plus an event; no service ever locks another service's data.)
Transactional outbox: saving state and event atomically
@Entity
@Table(name = "outbox")
public class OutboxEvent {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String aggregateId;
String type;
@Column(columnDefinition = "text") String payload;
Instant createdAt = Instant.now();
boolean published;
// constructors, getters, markPublished()
}
@Service
public class OrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
private final JsonMapper json;
public OrderService(OrderRepository orders, OutboxRepository outbox, JsonMapper json) {
this.orders = orders;
this.outbox = outbox;
this.json = json;
}
@Transactional // order row and outbox row commit together, or not at all
public Order create(CreateOrder cmd) {
Order order = orders.save(Order.pending(cmd));
outbox.save(new OutboxEvent(order.getNumber(), "OrderCreated",
json.writeValueAsString(new OrderCreated(order.getNumber(), cmd.lines(), cmd.total()))));
return order;
}
}
@Component
public class OutboxRelay {
private final OutboxRepository outbox;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(OutboxRepository outbox, KafkaTemplate<String, String> kafka) {
this.outbox = outbox;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
@SchedulerLock(name = "outboxRelay")
@Transactional
public void publish() {
for (OutboxEvent e : outbox.findTop100ByPublishedFalseOrderByIdAsc()) {
kafka.send("orders." + e.getType(), e.getAggregateId(), e.getPayload()).join();
e.markPublished();
}
}
}
order WN-10231 saved + outbox row (OrderCreated) in one commit
OutboxRelay: published OrderCreated for WN-10231 to orders.OrderCreated
(if Kafka is down, the row stays unpublished and is retried — no lost events)
Orchestration: a saga state machine in the Order Service
public enum SagaStep { RESERVING_STOCK, CHARGING_PAYMENT, COMPLETED, COMPENSATING, CANCELLED }
@Component
public class PlaceOrderSaga {
private final SagaRepository sagas;
private final CommandPublisher commands; // publishes commands via the outbox
public PlaceOrderSaga(SagaRepository sagas, CommandPublisher commands) {
this.sagas = sagas;
this.commands = commands;
}
@Transactional
public void start(Order order) {
sagas.save(new SagaState(order.getNumber(), SagaStep.RESERVING_STOCK));
commands.send("inventory.reserve", new ReserveStock(order.getNumber(), order.lines()));
}
@KafkaListener(topics = "inventory.replies", groupId = "order-saga")
@Transactional
public void onStockReply(StockReply reply) {
SagaState saga = sagas.findById(reply.orderId()).orElseThrow();
if (saga.getStep() != SagaStep.RESERVING_STOCK) return; // idempotent: ignore duplicates
if (reply.success()) {
saga.setStep(SagaStep.CHARGING_PAYMENT);
commands.send("payment.charge", new ChargePayment(reply.orderId(), saga.getTotal()));
} else {
saga.setStep(SagaStep.CANCELLED);
commands.send("order.cancel", new CancelOrder(reply.orderId(), "Out of stock"));
}
}
@KafkaListener(topics = "payment.replies", groupId = "order-saga")
@Transactional
public void onPaymentReply(PaymentReply reply) {
SagaState saga = sagas.findById(reply.orderId()).orElseThrow();
if (saga.getStep() != SagaStep.CHARGING_PAYMENT) return;
if (reply.success()) {
saga.setStep(SagaStep.COMPLETED);
commands.send("order.confirm", new ConfirmOrder(reply.orderId()));
} else {
saga.setStep(SagaStep.COMPENSATING);
commands.send("inventory.release", new ReleaseStock(reply.orderId())); // compensation
commands.send("order.cancel", new CancelOrder(reply.orderId(), "Payment declined"));
}
}
}
WN-10231: RESERVING_STOCK -> CHARGING_PAYMENT -> COMPLETED
WN-10232: RESERVING_STOCK -> CHARGING_PAYMENT -> COMPENSATING (stock released, order cancelled: Payment declined)
Common Mistakes
- Trying to use one @Transactional across service boundaries — it only covers the local database.
- Publishing events directly after commit without an outbox, losing events when the broker is unavailable.
- Forgetting compensating actions until a failure happens in production.
- Non-idempotent saga steps that double-charge or double-reserve on redelivery.
- Performing irreversible actions (emails, shipping) early in the saga before later steps can fail.
Key Points to Remember
- Microservices avoid global transactions; sagas coordinate a series of local transactions.
- Choreography uses events between services; orchestration uses a central coordinator.
- Every step needs a compensating action for failures after it.
- The transactional outbox makes "update database + publish event" reliable.
- All saga participants must be idempotent; place irreversible steps last.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.