Saga Pattern: Coordinating Distributed Transactions with Compensation
Deep Dive: Saga Internals and Failure Semantics
A Saga replaces fragile distributed 2PC with a sequence of local transactions, each backed by an explicit compensating transaction.
🎯 System Scenario & Requirements
System Goal
A Saga replaces fragile distributed 2PC with a sequence of local transactions, each backed by an explicit compensating transaction.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- TLDR: A Saga replaces fragile distributed 2PC with a sequence of local transactions, each backed by an explicit compensating transaction.
- Use orchestration when workflow control needs a single brain; use choreography when services must stay loosely coupled.
- Compensating Transactions Are Not Rollbacks A compensating transaction is a new, forward-moving transaction that reverses the business effect of a prior step — it is not a database rollback.
Constraints & Assumptions
- Compensating Transactions Are Not Rollbacks A compensating transaction is a new, forward-moving transaction that reverses the business effect of a prior step — it is not a database rollback.
- This distinction has real implications: - ReleaseInventoryCommand tells the inventory service "release reservation R-4892 ." The reservation record is not deleted; it is marked released with a timestamp and reason.
- No architecture pattern is free.
- Sagas trade atomic rollback for eventual consistency and explicit operational complexity.
⚡ Execution Stages & Decision Trace
Stage 1 — Java Frameworks for Saga Orchestration
Eventuate Tram: Saga Coordination Without a Dedicated Framework Eventuate Tram (a lightweight saga/transactional messaging library by Chris Richardson) implements sagas as SagaDefinition objects with explicit step seque…
SagaDefinition chains steps with .withCompensation() — Eventuate Tram executes compensations in reverse step order automatically on failure.
Deep Dive
Scenario context
A Saga replaces fragile distributed 2PC with a sequence of local transactions, each backed by an explicit compensating transaction. Use orchestration when workflow control needs a single brain; use choreography when services must stay loosely coupled.
flowchart TD
S([Start]) --> INV[ReserveInventory]
INV -->|InventoryReservedEvent| PAY[AuthorizePayment]
INV -->|InventoryReservationFailed…
This walkthrough focuses on **Deep Dive: Saga Internals and Failure Semantics**.
Before making a design choice, clarify:
- Compensating Transactions Are Not Rollbacks A compensating transaction is a new, forward-moving transaction that reverses the business effect of a prior step — it is not a database rollback.
- This distinction has real implications: - ReleaseInventoryCommand tells the inventory service "release reservation R-4892 ." The reservation record is not deleted; it is marked released with a timestamp and reason.
- - Compensations must be idempotent : if ReleaseInventoryCommand is delivered twice (broker retry, network hiccup), the second delivery must be a safe no-op, not a double-release.
**Concepts in play:** event-driven-architecture, graph-traversal-algorithms, jvm-internals-and-garbage-collection, message-queues
## Stage 1 — Java Frameworks for Saga Orchestration
- Eventuate Tram: Saga Coordination Without a Dedicated Framework Eventuate Tram (a lightweight saga/transactional messaging library by Chris Richardson) implements sagas as SagaDefinition objects with explicit step seque…
- SagaDefinition chains steps with .withCompensation() — Eventuate Tram executes compensations in reverse step order automatically on failure.
- No central orchestrator wakeup loop; the framework manages message dispatch and state persistence via JDBC.
- For a full deep-dive on Eventuate Tram saga state management and transactional messaging patterns, a dedicated follow-up post is planned.
*Applies [**event-driven-architecture**](/concept/event-driven-architecture) to the decision above.*
```java
public class CheckoutSagaDefinition implements SimpleSaga {
private SagaDefinition sagaDefinition =
step()
.invokeParticipant(this::reserveInventory)
.withCompensation(this::releaseInventory)
.step()
.invokeParticipant(this::authorizePayment)
.withCompensation(this::refundPayment)
.step()
.invokeParticipant(this::createShipment)
.build();
@Override
public SagaDefinition getSagaDefinition() { return sagaDefinition; }
private CommandWithDestination reserveInventory(CheckoutSagaData data) {
return send(new ReserveInventoryCommand(data.getOrderId(), data.getItems()))
.to("inventoryService").build();
}
private CommandWithDestination releaseInventory(CheckoutSagaData data) {
return send(new ReleaseInventoryCommand(data.getOrderId(), data.getReservationId()))
.to("inventoryService").build();
}
// authorizePayment, refundPayment, createShipment follow the same pattern
}
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.9.3</version>
</dependency>
Observable outcome: For a full deep-dive on Eventuate Tram saga state management and transactional messaging patterns, a dedicated follow-up post is planned.
Stage 2 — Orchestration in Code: The Axon Checkout Saga
- The saga class below is the single source of truth for the checkout workflow.
- Axon persists its fields between events, so the saga survives service restarts mid-flight without losing state.
- Key annotations: @StartSaga creates a new saga instance per trigger event.
- @SagaEventHandler(associationProperty = "orderId") routes events to the right instance by orderId .
@Saga
public class CheckoutSaga {
@Inject
private transient CommandGateway commandGateway; // transient = not serialized with saga state
private String orderId;
private String reservationId;
private boolean paymentAuthorized;
@StartSaga
@SagaEventHandler(associationProperty = "orderId")
public void on(OrderPlacedEvent event) {
this.orderId = event.orderId();
commandGateway.send(new ReserveInventoryCommand(event.orderId(), event.items()));
}
@SagaEventHandler(associationProperty = "orderId")
public void on(InventoryReservedEvent event) {
this.reservationId = event.reservationId();
commandGateway.send(new AuthorizePaymentCommand(event.orderId(), event.totalCents()));
}
@SagaEventHandler(associationProperty = "orderId")
public void on(PaymentAuthorizedEvent event) {
this.paymentAuthorized = true;
commandGateway.send(new CreateShipmentCommand(event.orderId()));
SagaLifecycle.end();
}
// Compensation: inventory step failed — nothing reserved yet, just cancel the order
@SagaEventHandler(associationProperty = "orderId")
public void on(InventoryReservationFailedEvent event) {
commandGateway.send(new CancelOrderCommand(event.orderId(), "inventory_unavailable"));
SagaLifecycle.end();
}
// Compensation: payment failed — release reservation first, then cancel order
@SagaEventHandler(associationProperty = "orderId")
public void on(PaymentFailedEvent event) {
commandGateway.send(new ReleaseInventoryCommand(orderId, reservationId));
commandGateway.send(new CancelOrderCommand(orderId, "payment_failed"));
SagaLifecycle.end();
}
}
sequenceDiagram
participant O as Orchestrator
participant A as InventoryService
participant B as PaymentService
participant C as ShipmentService
O->>A: ReserveInventory
A-->>O: InventoryReserved
O->>B: AuthorizePayment
B-->>O: PaymentFailed
O->>A: ReleaseInventory
A-->>O: InventoryReleased
Note over O: Saga compensated
Observable outcome: @SagaEventHandler(associationProperty = "orderId") routes events to the right instance by orderId .
Stage 3 — Choreography in Code: Spring Kafka Payment Participant
- In the choreography style there is no central saga class.
- Each service subscribes to events and publishes its own outcome.
- The workflow emerges from the chain of reactions across services.
- The inventory service similarly listens on the payment-failed topic and releases its reservation.
@Component
public class PaymentSagaParticipant {
private final KafkaTemplate<String, Object> kafka;
public PaymentSagaParticipant(KafkaTemplate<String, Object> kafka) {
this.kafka = kafka;
}
@KafkaListener(topics = "order-placed")
public void onOrderPlaced(OrderPlacedEvent event) {
try {
paymentService.authorize(event.orderId(), event.totalCents());
kafka.send("payment-authorized", new PaymentAuthorizedEvent(event.orderId()));
} catch (PaymentDeclinedException e) {
kafka.send("payment-failed",
new PaymentFailedEvent(event.orderId(), e.getReason()));
}
}
}
sequenceDiagram
participant A as OrderService
participant B as InventoryService
participant C as PaymentService
A->>B: OrderPlaced event
B->>C: InventoryReserved event
C->>A: PaymentFailed event
A->>B: OrderCancelled event
B-->>A: InventoryReleased event
Note over A,C: Compensation via events
Observable outcome: The inventory service similarly listens on the payment-failed topic and releases its reservation.
Stage 4 — Real-World Applications
- Sagas appear wherever a multi-step business process spans service boundaries: - Travel booking: flight → hotel → car rental.
- If the car is unavailable, compensate by cancelling hotel and flight in reverse order.
- Bank money transfer: debit source → credit destination → notify parties.
- Compensation reverses the debit if the credit write fails.
Observable outcome: Compensation reverses the debit if the credit write fails.
Stage 5 — Operator Field Note: Stuck Sagas and Dead Letters
- Alert on saga active duration — a checkout saga open for more than five minutes is almost certainly stuck.
- Axon routes commands that exhaust retries to a Dead Letter Queue (DLQ) ; inspect the entry with its orderId , replay if the failure was transient, or escalate for manual data correction.
Observable outcome: Axon routes commands that exhaust retries to a Dead Letter Queue (DLQ) ; inspect the entry with its orderId , replay if the failure was transient, or escalate for manual data correction.
End-to-end outcome
After working through the stages, you should be able to explain:
- Design compensations before happy paths.
- If you cannot name the compensating transaction for each step at design time, the saga is not production-ready.
Alternatives considered
Decision Guide: When to Reach for Orchestration vs Choreography
Decision Guide: When to Reach for Orchestration vs Choreography
Failure and degraded modes
Compensating Transactions Are Not Rollbacks
- Detection: A compensating transaction is a new, forward-moving transaction that reverses the business effect of a prior step — it is not a database rollback.
- User or operator impact: A compensating transaction is a new, forward-moving transaction that reverses the business effect of a prior step — it is not a database rollback.
Performance Analysis
- Detection: Sagas trade atomic rollback for latency decomposition .
- User or operator impact: Sagas trade atomic rollback for latency decomposition .
Trade-offs & Failure Modes: Saga Trade-offs and the Failure Modes That Bite First
- Detection: No architecture pattern is free.
- User or operator impact: No architecture pattern is free.
