TechLead
Lesson 9 of 27
6 min read
Software Architecture

Saga Pattern

Learn orchestration vs choreography sagas, compensation logic, and managing distributed transactions

What is the Saga Pattern?

The Saga Pattern manages distributed transactions across multiple microservices. Instead of a single ACID transaction spanning multiple databases, a saga breaks the transaction into a sequence of local transactions. Each local transaction updates a single service and publishes an event or message to trigger the next step. If any step fails, compensating transactions undo the preceding steps.

Why Sagas?

  • No distributed transactions: Two-phase commit (2PC) does not scale in microservices — sagas provide an alternative
  • Eventual consistency: Sagas embrace eventual consistency rather than trying to achieve strong consistency across services
  • Fault tolerance: Each step has a compensating action, making the system resilient to partial failures
  • Autonomy: Services remain independent — they do not need to participate in distributed locking protocols

Orchestration-Based Saga

In an orchestration-based saga, a central saga orchestrator tells each participant what to do. The orchestrator maintains the state of the saga and decides the next step based on the outcome of each local transaction.

// Saga step definition
interface SagaStep {
  name: string;
  execute(context: TContext): Promise;
  compensate(context: TContext): Promise;
}

// Saga orchestrator
class SagaOrchestrator {
  private steps: SagaStep[] = [];

  addStep(step: SagaStep): SagaOrchestrator {
    this.steps.push(step);
    return this;
  }

  async execute(initialContext: TContext): Promise {
    let context = initialContext;
    const executedSteps: SagaStep[] = [];

    for (const step of this.steps) {
      try {
        console.log(`Executing saga step: ${step.name}`);
        context = await step.execute(context);
        executedSteps.push(step);
      } catch (error) {
        console.error(`Saga step ${step.name} failed:`, error);
        // Compensate in reverse order
        for (const executedStep of executedSteps.reverse()) {
          try {
            console.log(`Compensating step: ${executedStep.name}`);
            context = await executedStep.compensate(context);
          } catch (compensationError) {
            console.error(
              `Compensation failed for ${executedStep.name}:`,
              compensationError
            );
            // Log to dead-letter queue for manual intervention
          }
        }
        throw new SagaFailedError(step.name, error as Error);
      }
    }

    return context;
  }
}

// Order saga context
interface OrderSagaContext {
  orderId: string;
  customerId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
  totalAmount: number;
  reservationId?: string;
  paymentId?: string;
  shipmentId?: string;
}

// Concrete saga steps
const reserveInventoryStep: SagaStep = {
  name: "ReserveInventory",
  async execute(context) {
    const result = await inventoryService.reserve(context.items);
    return { ...context, reservationId: result.reservationId };
  },
  async compensate(context) {
    if (context.reservationId) {
      await inventoryService.release(context.reservationId);
    }
    return { ...context, reservationId: undefined };
  },
};

const processPaymentStep: SagaStep = {
  name: "ProcessPayment",
  async execute(context) {
    const result = await paymentService.charge(
      context.totalAmount,
      context.customerId
    );
    return { ...context, paymentId: result.paymentId };
  },
  async compensate(context) {
    if (context.paymentId) {
      await paymentService.refund(context.paymentId);
    }
    return { ...context, paymentId: undefined };
  },
};

const createShipmentStep: SagaStep = {
  name: "CreateShipment",
  async execute(context) {
    const result = await shippingService.createShipment(
      context.orderId,
      context.items
    );
    return { ...context, shipmentId: result.shipmentId };
  },
  async compensate(context) {
    if (context.shipmentId) {
      await shippingService.cancelShipment(context.shipmentId);
    }
    return { ...context, shipmentId: undefined };
  },
};

// Composing the saga
const orderSaga = new SagaOrchestrator()
  .addStep(reserveInventoryStep)
  .addStep(processPaymentStep)
  .addStep(createShipmentStep);

// Execute
const result = await orderSaga.execute({
  orderId: "ord-123",
  customerId: "cust-456",
  items: [{ productId: "prod-1", quantity: 2, price: 29.99 }],
  totalAmount: 59.98,
});

Choreography-Based Saga

In a choreography-based saga, there is no central orchestrator. Each service listens for events and decides independently what to do next. Services publish events after completing their local transaction, and other services react to those events.

// Choreography: Each service handles its part

// Order Service
class OrderService {
  async placeOrder(command: PlaceOrderCommand): Promise {
    const order = Order.create(command);
    await this.orderRepo.save(order);
    await this.eventBus.publish({
      eventType: "OrderCreated",
      data: { orderId: order.id, items: order.items, customerId: order.customerId },
    });
  }

  // Listen for downstream events
  async onPaymentFailed(event: PaymentFailedEvent): Promise {
    const order = await this.orderRepo.findById(event.data.orderId);
    order.markFailed("Payment declined");
    await this.orderRepo.save(order);
  }

  async onShipmentCreated(event: ShipmentCreatedEvent): Promise {
    const order = await this.orderRepo.findById(event.data.orderId);
    order.markShipped(event.data.trackingId);
    await this.orderRepo.save(order);
  }
}

// Inventory Service reacts to OrderCreated
class InventoryService {
  async onOrderCreated(event: OrderCreatedEvent): Promise {
    try {
      const reservation = await this.reserve(event.data.items);
      await this.eventBus.publish({
        eventType: "InventoryReserved",
        data: { orderId: event.data.orderId, reservationId: reservation.id },
      });
    } catch (error) {
      await this.eventBus.publish({
        eventType: "InventoryReservationFailed",
        data: { orderId: event.data.orderId, reason: (error as Error).message },
      });
    }
  }

  // Compensate if payment fails
  async onPaymentFailed(event: PaymentFailedEvent): Promise {
    await this.release(event.data.reservationId);
  }
}

// Payment Service reacts to InventoryReserved
class PaymentService {
  async onInventoryReserved(event: InventoryReservedEvent): Promise {
    try {
      const payment = await this.charge(event.data.orderId);
      await this.eventBus.publish({
        eventType: "PaymentProcessed",
        data: { orderId: event.data.orderId, paymentId: payment.id },
      });
    } catch (error) {
      await this.eventBus.publish({
        eventType: "PaymentFailed",
        data: { orderId: event.data.orderId, reservationId: event.data.reservationId },
      });
    }
  }
}

Orchestration vs Choreography

Aspect Orchestration Choreography
ComplexityLogic centralized in orchestratorLogic distributed across services
CouplingOrchestrator knows all participantsServices only know about events
VisibilityEasy to see the full flowRequires event tracing to understand flow
Adding stepsModify orchestratorAdd new event listener
Best forComplex workflows, many stepsSimple flows, few participants

Saga State Machine

// Persistent saga with state machine
enum SagaStatus {
  STARTED = "STARTED",
  INVENTORY_RESERVED = "INVENTORY_RESERVED",
  PAYMENT_PROCESSED = "PAYMENT_PROCESSED",
  COMPLETED = "COMPLETED",
  COMPENSATING = "COMPENSATING",
  FAILED = "FAILED",
}

interface SagaState {
  sagaId: string;
  orderId: string;
  status: SagaStatus;
  context: OrderSagaContext;
  createdAt: Date;
  updatedAt: Date;
}

class PersistentOrderSaga {
  constructor(
    private readonly sagaRepo: SagaRepository,
    private readonly services: SagaServices
  ) {}

  async start(context: OrderSagaContext): Promise {
    const saga: SagaState = {
      sagaId: generateId(),
      orderId: context.orderId,
      status: SagaStatus.STARTED,
      context,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    await this.sagaRepo.save(saga);
    await this.advance(saga);
    return saga.sagaId;
  }

  private async advance(saga: SagaState): Promise {
    try {
      switch (saga.status) {
        case SagaStatus.STARTED: {
          const reservation = await this.services.inventory.reserve(saga.context.items);
          saga.context.reservationId = reservation.id;
          saga.status = SagaStatus.INVENTORY_RESERVED;
          await this.sagaRepo.save(saga);
          await this.advance(saga);
          break;
        }
        case SagaStatus.INVENTORY_RESERVED: {
          const payment = await this.services.payment.charge(saga.context.totalAmount);
          saga.context.paymentId = payment.id;
          saga.status = SagaStatus.PAYMENT_PROCESSED;
          await this.sagaRepo.save(saga);
          await this.advance(saga);
          break;
        }
        case SagaStatus.PAYMENT_PROCESSED: {
          await this.services.shipping.createShipment(saga.orderId);
          saga.status = SagaStatus.COMPLETED;
          await this.sagaRepo.save(saga);
          break;
        }
      }
    } catch (error) {
      saga.status = SagaStatus.COMPENSATING;
      await this.sagaRepo.save(saga);
      await this.compensate(saga);
    }
  }

  private async compensate(saga: SagaState): Promise {
    // Compensate in reverse order based on what was completed
    if (saga.context.paymentId) {
      await this.services.payment.refund(saga.context.paymentId);
    }
    if (saga.context.reservationId) {
      await this.services.inventory.release(saga.context.reservationId);
    }
    saga.status = SagaStatus.FAILED;
    await this.sagaRepo.save(saga);
  }
}

Saga Best Practices

  • Idempotent operations: Both execute and compensate steps must be idempotent — they may be retried
  • Persist saga state: Store the saga state in a database so it survives process crashes
  • Semantic locks: Use status flags (e.g., PENDING) to prevent concurrent modifications during saga execution
  • Timeouts: Set timeouts for each step — if a service does not respond, trigger compensation
  • Dead-letter handling: When compensation itself fails, route to a dead-letter queue for manual resolution

Continue Learning