1. Why Two-Phase Commit Doesn't Fit Microservices
A single database transaction relies on ACID guarantees the database engine enforces directly. The classic distributed equivalent, two-phase commit (2PC), tries to extend that same guarantee across multiple databases: a coordinator asks every participant to prepare to commit, waits for all of them to confirm they can, then tells everyone to actually commit. It works — but it has properties that make it a poor fit for the independent, separately-owned services this course has built since Week 9.
- Blocking — every participant holds its local locks from "prepare" until the coordinator's final decision arrives; if the coordinator or network is slow, every participant's resources stay locked, including rows unrelated services need.
- A single coordinator is a new point of failure — if it crashes between "prepare" and "commit," participants can be left holding a decision they can't resolve on their own.
- Tight coupling — every participating service has to support the same distributed transaction protocol and be simultaneously available for the transaction to complete at all, which works against the entire point of services that can deploy, fail, and scale independently.
The Saga pattern takes a different tradeoff entirely: instead of one atomic operation, a saga is a sequence of local transactions, each in its own service and its own database, where each step publishes an event that triggers the next. There's no cross-service lock held anywhere — the tradeoff is giving up strict consistency in favor of eventual consistency, and taking on the responsibility of undoing already-completed steps if a later one fails.
2. Choreography vs. Orchestration Sagas
Consider placing an order that needs to reserve inventory and charge a payment, spanning three services. Two ways to coordinate the saga:
Choreography — each service reacts to events from the previous step and publishes its own; there's no central coordinator.
// order-service
@Transactional
void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request, "PENDING"));
eventPublisher.publish(new OrderCreated(order.id(), request.items()));
}
// inventory-service, listening for OrderCreated
@KafkaListener(topics = "order-events")
void onOrderCreated(OrderCreated event) {
boolean reserved = inventoryService.tryReserve(event.items());
if (reserved) {
eventPublisher.publish(new InventoryReserved(event.orderId()));
} else {
eventPublisher.publish(new InventoryReservationFailed(event.orderId()));
}
}
// order-service, listening for the compensating failure event
@KafkaListener(topics = "inventory-events")
void onInventoryReservationFailed(InventoryReservationFailed event) {
orderRepository.updateStatus(event.orderId(), "CANCELLED"); // compensating action
}
Orchestration — a dedicated orchestrator service explicitly calls each step in sequence and decides what happens next based on the result, rather than services reacting to each other's events independently:
class OrderSagaOrchestrator {
void placeOrder(OrderRequest request) {
Order order = orderService.create(request);
try {
inventoryClient.reserve(order.id(), request.items());
paymentClient.charge(order.id(), request.total());
orderService.confirm(order.id());
} catch (InventoryUnavailableException e) {
orderService.cancel(order.id()); // compensate
} catch (PaymentFailedException e) {
inventoryClient.release(order.id()); // compensate step 1
orderService.cancel(order.id());
}
}
}
Choreography avoids a central point of coordination and keeps services fully decoupled, but the overall saga's logic ends up scattered across every participant's event handlers — understanding the full flow means reading several services' code. Orchestration makes the entire sequence, including every compensating action, readable in one place, at the cost of the orchestrator needing to know about every step and becoming a coordination dependency of its own. Neither is universally "right" — choreography tends to fit a small number of steps better; orchestration tends to stay more maintainable as the number of steps and failure paths grows.
A saga's hard part isn't the happy path — it's designing what "undo" means for every step, before writing the forward logic. "Release reserved inventory" and "refund a charged payment" both need to be real, tested operations your service supports, not an afterthought bolted on once a failure in production reveals there's no way to undo step 2.
3. The Transactional Outbox Pattern
Both saga styles above depend on a subtle but critical guarantee: when a service
commits a local change, its corresponding event must also get published —
reliably, exactly once, even if the service crashes right after committing. Publishing
directly inside the same method, right after save(), has a real failure
window: if the process crashes between the database commit and the message broker
publish succeeding, the local change is durable but the event is lost forever, and
every downstream service the saga depends on never finds out.
The transactional outbox pattern closes that gap by writing the event
to an outbox table in the same local transaction as the actual
business change — both succeed or both roll back together, guaranteed by the single
database's own ACID properties, the same guarantee this whole lesson opened by saying
doesn't extend across services.
@Transactional
void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request, "PENDING"));
OutboxEvent event = new OutboxEvent(
"Order", order.id().toString(), "OrderCreated",
toJson(new OrderCreated(order.id(), request.items())));
outboxRepository.save(event); // same transaction, same commit-or-rollback
}
A separate poller (or, more robustly, a change-data-capture tool like Debezium reading
the database's write-ahead log) then reads unpublished rows from the outbox
table and publishes them to the message broker, marking each one published only after
a confirmed send:
@Scheduled(fixedDelay = 500)
void publishPendingEvents() {
List<OutboxEvent> pending = outboxRepository.findByPublishedFalse();
for (OutboxEvent event : pending) {
kafkaTemplate.send(event.aggregateType() + "-events", event.payload());
event.markPublished();
outboxRepository.save(event);
}
}
This design accepts at-least-once delivery rather than exactly-once —
a crash between sending and marking published can cause a duplicate — which is why
every event consumer in a saga needs to be idempotent: processing the
same OrderCreated event twice must produce the same end state as
processing it once, typically by checking whether that event's ID has already been
handled before acting on it.
Events aren't published the instant the local transaction commits — they're published on the next poll cycle, adding up to the poll interval's worth of latency. That's a deliberate, worthwhile trade: the alternative, publishing directly inside the request path, can silently lose events on a crash, which is a correctness bug a saga can't recover from on its own.
4. Hands-on Exercise
Build a choreographed saga with a transactional outbox and a deliberate failure path
Extend the two services from Weeks 9–10 with an order-placement saga that can genuinely fail and compensate.
Requirements:
- Add an
outboxtable to your order service, and write events to it in the same transaction as the business change, per Section 3. - Build a polling (or Debezium-based) publisher that reads unpublished outbox rows and publishes them to Kafka/RabbitMQ, marking each published only after a confirmed send.
- Implement a choreographed saga: order creation triggers an inventory reservation attempt, which publishes either a success or failure event the order service listens for.
- Implement the compensating action for the failure path — the order service transitioning a pending order to cancelled when it receives a reservation-failed event.
- Make your event consumer idempotent: process the same event twice (simulate an outbox duplicate) and confirm the end state is identical to processing it once.
Track processed event IDs in a small dedicated table (processed_event_id, a unique constraint) and check it before acting on an incoming event — that's the simplest reliable idempotency mechanism, and it's the same "have I seen this before" check regardless of which event type you're consuming.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
Why does two-phase commit's "blocking" property make it a poor fit for independently-owned microservices?
Why does two-phase commit's "blocking" property make it a poor fit for independently-owned microservices?
Every participant holds its local locks from the "prepare" phase until the coordinator's final commit decision arrives, which means one slow or unavailable participant — or the coordinator itself — can leave every other participant's resources locked, including rows completely unrelated services need. That contradicts the whole premise of services that are supposed to deploy, scale, and fail independently of one another.
Q2
What's the core tradeoff between a choreography-based saga and an orchestration-based one?
What's the core tradeoff between a choreography-based saga and an orchestration-based one?
Choreography keeps every service fully decoupled, reacting only to events, but scatters the overall saga's logic across every participant's event handlers — there's no single place to read the whole flow. Orchestration centralizes the sequence and every compensating action in one readable place, at the cost of the orchestrator needing to know about, and depend on, every step in the saga.
Q3
Why does the transactional outbox pattern require event consumers to be idempotent?
Why does the transactional outbox pattern require event consumers to be idempotent?
The outbox pattern guarantees at-least-once delivery, not exactly-once — a crash between successfully publishing an event and marking it published in the outbox table can cause the same event to be published again on the next poll cycle. Consumers have to treat processing the same event twice as safe, typically by tracking which event IDs have already been handled, or the duplicate would cause the same side effect (like reserving inventory) to happen twice.