Distributed Transactions with Sagas
You're building an e-commerce system. Placing an order needs to: (1) reserve inventory, (2) charge payment, (3) create shipment. Each operation is in a different microservice with its own database.
What if payment fails after inventory is reserved? You can't just rollback - there's no distributed transaction across services.
Sagas solve this by breaking the transaction into steps with compensations.The Problem: No Distributed ACID
In a monolith, you can wrap everything in a database transaction. If anything fails, the whole thing rolls back atomically.
In microservices: Inventory Service uses MongoDB, Payment Service uses PostgreSQL, Shipping Service uses DynamoDB. There's no way to do a single atomic transaction across all three.
Enter the Saga Pattern
A Saga is a sequence of local transactions. Each step has a compensating transaction that undoes its effects if a later step fails.
Order Saga: Place Order
═══════════════════════════════════════════════════════════════════
Step 1: Reserve Inventory ←──── Compensate: Release Inventory
↓
Step 2: Charge Payment ←──── Compensate: Refund Payment
↓
Step 3: Create Shipment ←──── Compensate: Cancel Shipment
↓
SUCCESS!
If Step 2 fails:
───────────────────
Step 1: Reserve Inventory ✓
Step 2: Charge Payment ✗ (card declined)
└─→ Run compensation for Step 1: Release Inventory
Result: System back to consistent state
Saga Execution: Orchestration vs Choreography
ORCHESTRATION
A central Saga Orchestrator tells each service what to do.
Orchestrator
│
├──▶ Inventory: "Reserve"
│ ↓ (done)
├──▶ Payment: "Charge"
│ ↓ (done)
└──▶ Shipping: "Create"
Pros: Easy to understand, central logic
Cons: Single point of failure
CHOREOGRAPHY
Services react to events and publish their own events.
Order Created
│
Inventory ──▶ InventoryReserved
│
Payment ────▶ PaymentCharged
│
Shipping ───▶ ShipmentCreated
Pros: Loose coupling, no SPOF
Cons: Harder to track flow
Implementing an Orchestrated Saga
class PlaceOrderSaga {
private steps: SagaStep[] = [
{
name: 'Reserve Inventory',
execute: (ctx) => this.inventoryService.reserve(ctx.orderId, ctx.items),
compensate: (ctx) => this.inventoryService.release(ctx.orderId)
},
{
name: 'Charge Payment',
execute: (ctx) => this.paymentService.charge(ctx.orderId, ctx.amount),
compensate: (ctx) => this.paymentService.refund(ctx.orderId)
},
{
name: 'Create Shipment',
execute: (ctx) => this.shippingService.create(ctx.orderId, ctx.address),
compensate: (ctx) => this.shippingService.cancel(ctx.orderId)
}
];
async execute(context: OrderContext): Promise<SagaResult> {
const completedSteps: SagaStep[] = [];
for (const step of this.steps) {
try {
await step.execute(context);
completedSteps.push(step);
} catch (error) {
// Step failed! Run compensations in reverse order
console.log(`Step "${step.name}" failed. Compensating...`);
for (const completed of completedSteps.reverse()) {
try {
await completed.compensate(context);
} catch (compError) {
// Log and alert - compensation failed!
console.error(`Compensation for "${completed.name}" failed!`);
}
}
return { success: false, error };
}
}
return { success: true };
}
}
Implementing a Choreographed Saga
// Inventory Service
class InventoryService {
@EventHandler('OrderCreated')
async onOrderCreated(event: OrderCreated) {
try {
await this.reserveInventory(event.orderId, event.items);
await this.publish(new InventoryReserved(event.orderId));
} catch (error) {
await this.publish(new InventoryReservationFailed(event.orderId, error));
}
}
@EventHandler('PaymentFailed')
async onPaymentFailed(event: PaymentFailed) {
// Compensate: release the reserved inventory
await this.releaseInventory(event.orderId);
await this.publish(new InventoryReleased(event.orderId));
}
}
// Payment Service
class PaymentService {
@EventHandler('InventoryReserved')
async onInventoryReserved(event: InventoryReserved) {
try {
await this.chargePayment(event.orderId);
await this.publish(new PaymentCharged(event.orderId));
} catch (error) {
await this.publish(new PaymentFailed(event.orderId, error));
}
}
}
// Shipping Service
class ShippingService {
@EventHandler('PaymentCharged')
async onPaymentCharged(event: PaymentCharged) {
await this.createShipment(event.orderId);
await this.publish(new ShipmentCreated(event.orderId));
}
}
Saga State Machine
Sagas often model as state machines, making failure handling explicit:
┌─────────────────┐
┌───────────────│ STARTED │
│ └────────┬────────┘
│ │
│ ▼
│ ┌─────────────────┐
InventoryFailed │ │ RESERVING │
│ │ INVENTORY │
│ └────────┬────────┘
│ │
▼ ▼ InventoryReserved
┌─────────────────┐ ┌─────────────────┐
│ FAILED │◀─────│ CHARGING │
│ (compensating) │ │ PAYMENT │
└─────────────────┘ └────────┬────────┘
▲ │
│ PaymentFailed ▼ PaymentCharged
│ ┌─────────────────┐
└───────────────│ CREATING │
│ SHIPMENT │
└────────┬────────┘
│
▼ ShipmentCreated
┌─────────────────┐
│ COMPLETED │
└─────────────────┘
Handling Edge Cases
Idempotency is Critical
What if a message is delivered twice? Your handlers must be idempotent.
class PaymentService {
async chargePayment(orderId: string, amount: number) {
// Check if already charged (idempotency)
const existing = await this.db.findPayment(orderId);
if (existing) {
console.log(`Payment for ${orderId} already exists, skipping`);
return existing;
}
// Process payment
const payment = await this.processPayment(orderId, amount);
await this.db.savePayment(payment);
return payment;
}
}
Timeouts and Retries
- Timeouts: Set maximum wait time for each step
- Retries: Retry transient failures with exponential backoff
- Dead letter queue: Move failed sagas for manual review
- Monitoring: Alert on stuck sagas
Real-World: Uber's Saga Implementation
Uber built Cadence to manage complex workflows across their microservices. A ride request involves: matching drivers, calculating fares, processing payments, and managing surge pricing - all as a saga with compensations.
When a driver cancels after accepting, the saga compensates by re-matching, adjusting ETA, and potentially issuing credits to the rider.
Saga vs Two-Phase Commit
| Aspect | Two-Phase Commit | Saga |
|---|---|---|
| Consistency | Strong (ACID) | Eventual |
| Locking | Holds locks during prepare | No distributed locks |
| Failure Handling | Coordinator blocks on failure | Compensating transactions |
| Scalability | Poor (locks don't scale) | Good (async, decoupled) |
| Use Case | Single DB, critical consistency | Microservices, long-running |
Orchestration Frameworks
Key Takeaways
- Sagas replace distributed transactions - Local transactions + compensations = eventual consistency
- Every step needs a compensation - Plan for failure from the start
- Orchestration vs Choreography - Central control vs event-driven; choose based on complexity
- Idempotency is mandatory - Messages can be delivered multiple times
Next up: Assessment Quiz - Test your understanding of CQRS and Event Sourcing patterns.