Microservices & Distributed Systems

Saga Pattern: Distributed Transactions Without Two-Phase Commit

MatterAI
MatterAI
14 min read·

Saga Pattern: Distributed Transactions Without Two-Phase Commit

A distributed transaction across services cannot use a database transaction: there is no single database. Two-phase commit (2PC) exists but is slow, fragile, and locks resources across the network. The saga pattern is the practical alternative: a sequence of local transactions, each with a compensating action that undoes it if a later step fails.

The Problem

Consider an order flow spanning three services:

orders: create order
payments: charge card
inventory: reserve stock

If the payment succeeds but inventory fails, you have an inconsistent state: a paid order with no stock. A local transaction cannot span these services. The saga pattern solves this with compensation:

Step 1: create order          compensation: cancel order
Step 2: charge card          compensation: refund card
Step 3: reserve stock        compensation: release stock

If step 3 fails, run compensations in reverse: release nothing, refund the card, cancel the order.

Choreography vs Orchestration

There are two ways to coordinate a saga.

Choreography: each service publishes events and reacts to events. No central coordinator.

orders ──order.created──► payments ──payment.charged──► inventory
   ▲                                                       │
   └──────────────order.cancelled◄──payment.failed─────────┘

Orchestration: a central coordinator (the saga) tells each service what to do and tracks the state.

                    ┌──────────────┐
                    │ order-saga   │
                    └──────┬───────┘
        create order       │       charge card
        ┌──────────────────┼──────────────────┐
        ▼                  ▼                  ▼
     orders            payments           inventory
AspectChoreographyOrchestration
CouplingLoose (events only)Tighter (coordinator)
VisibilityHard to traceCentral state machine
Failure handlingDistributed, complexCentralized, explicit
Best forSimple, few servicesComplex, many services

Orchestrated Saga: The State Machine

The orchestrated saga is a state machine. Each step is a command; each response advances the state:

// saga.ts — orchestrated saga with explicit states
type SagaState =
  | { status: "pending" }
  | { status: "order-created"; orderId: string }
  | { status: "payment-charged"; orderId: string; paymentId: string }
  | { status: "completed"; orderId: string }
  | { status: "compensating"; orderId: string }
  | { status: "failed"; orderId: string; reason: string };

const saga = {
  async run(orderId: string): Promise<SagaState> {
    let state: SagaState = { status: "pending" };
    try {
      await ordersClient.createOrder(orderId);
      state = { status: "order-created", orderId };

      await paymentsClient.chargeCard(orderId);
      state = { status: "payment-charged", orderId, paymentId: "pay_123" };

      await inventoryClient.reserveStock(orderId);
      state = { status: "completed", orderId };
      return state;
    } catch (err) {
      await this.compensate(state, err);
      return { status: "failed", orderId, reason: String(err) };
    }
  },

  async compensate(state: SagaState, err: unknown): Promise<void> {
    // Run compensations in reverse order of completed steps
    if (state.status === "payment-charged") {
      await paymentsClient.refundCard(state.paymentId);
    }
    if (state.status === "order-created") {
      await ordersClient.cancelOrder(state.orderId);
    }
  },
};

Choreographed Saga: Events and Handlers

Choreography uses events and idempotent handlers:

// payments service — reacts to order.created, emits payment.charged
broker.subscribe("order.created", async (event) => {
  try {
    await chargeCard(event.orderId);
    await broker.publish("payment.charged", { orderId: event.orderId });
  } catch (err) {
    await broker.publish("payment.failed", {
      orderId: event.orderId,
      reason: String(err),
    });
  }
});

// orders service — reacts to payment.failed, cancels the order
broker.subscribe("payment.failed", async (event) => {
  await cancelOrder(event.orderId);
  await broker.publish("order.cancelled", { orderId: event.orderId });
});

Choreography is simpler to write and looser in coupling, but the flow is implicit: you cannot see the whole saga in one place, and a missed event handler silently breaks the flow.

The Outbox Pattern: Reliable Event Publishing

Both styles depend on reliable event delivery. The outbox pattern guarantees that a state change and its event are published together:

// Write the state change and the outbox row in ONE local transaction
await db.transaction(async (tx) => {
  await tx.order.update({ id: orderId, status: "paid" });
  await tx.outbox.create({
    id: crypto.randomUUID(),
    type: "order.paid",
    payload: { orderId },
    publishedAt: null,
  });
});

// A relay worker polls the outbox and publishes to the broker
async function relayOutbox() {
  const rows = await db.outbox.findMany({
    where: { publishedAt: null },
    take: 100,
  });
  for (const row of rows) {
    await broker.publish(row.type, row.payload);
    await db.outbox.update({
      where: { id: row.id },
      data: { publishedAt: new Date() },
    });
  }
}

Without the outbox, you get the classic failure: the transaction commits, the event publish fails, and the saga stalls forever.

Idempotency: The Non-Negotiable Requirement

Sagas retry. Retries mean duplicate messages. Every handler must be idempotent:

// Idempotent handler — dedupe by event ID
broker.subscribe("payment.charged", async (event) => {
  const processed = await db.processedEvents.findUnique({
    where: { eventId: event.id },
  });
  if (processed) return; // already handled

  await db.transaction(async (tx) => {
    await tx.inventory.reserve({ orderId: event.orderId });
    await tx.processedEvents.create({ eventId: event.id });
  });
});

Compensation Design Rules

  • Compensations must be reliable: they can fail too. Retry them; if they keep failing, alert a human.
  • Compensations must be idempotent: refunding twice is a bug.
  • Design for partial failure: a compensation may run after the original step partially completed.
  • Log everything: the saga state and every compensation attempt belong in an audit trail.

When NOT to Use a Saga

  • If the steps can live in one service: a local transaction is strictly better.
  • If eventual consistency is unacceptable: sagas are eventually consistent by design. If you need read-your-writes across services, reconsider the architecture.
  • If you can use a single database: one database with transactions beats a saga every time.

Implementation Checklist

  • Choose choreography (simple) or orchestration (complex)
  • Define compensating actions for every step
  • Use the outbox pattern for reliable event publishing
  • Make every handler idempotent
  • Add retries with backoff for compensations
  • Persist saga state for recovery and audit
  • Alert on compensation failures
  • Consider whether a local transaction would suffice

MatterAI builds frontier AI infrastructure for engineering teams — from inference-optimized models to autonomous coding agents and agentic code reviews.

Explore what we're building:

  • Orbital IDE — Autonomous AI coding agent with background agents and deep codebase memory
  • AI Code Reviews — Agentic pre-commit reviews across GitHub, GitLab, and Bitbucket
  • Axon Models — Frontier-grade reasoning models at 70% lower inference cost

Get started free - https://app.matterai.so


Follow us on X · LinkedIn · GitHub

Share this Guide:

Ship Faster. Ship Safer.

Join thousands of engineering teams using MatterAI to autonomously build, review, and deploy code with enterprise-grade precision.

No credit card requiredSOC 2 Type IISetup in 2 min