🔥 0
0
Lesson 5 of 6 22 min +300 XP

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

Why Traditional Transactions Don't Work

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

What If a Step Never Responds?
  • 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's Cadence (now Temporal)

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

Temporal
Open source, Uber's Cadence fork
AWS Step Functions
Managed, serverless workflows
Camunda
BPMN-based workflow engine
Apache Airflow
Data pipeline orchestration

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.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Why can't you use traditional ACID transactions across microservices?

2

What is a compensating transaction in the Saga pattern?

3

What is the main difference between Choreography and Orchestration sagas?