Week 18: Distributed Transactions & the Saga Pattern

Every Prisma transaction since Week 4 has relied on a single database guaranteeing atomicity — either everything commits, or everything rolls back. That guarantee disappears the instant an operation spans two services with two separate databases, which Week 17's gRPC setup makes easy to build without thinking about. This week is about what actually replaces a database transaction once "the transaction" spans a network call: the Saga pattern, and the outbox pattern that makes it reliable.

Module 15 of 22 Week 18 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain why two-phase commit doesn't fit a microservices architecture
  • Implement a choreography-based saga with BullMQ and compensating actions
  • Use the transactional outbox pattern to publish events reliably

1. Why Two-Phase Commit Doesn't Fit Microservices

A single database transaction relies on ACID guarantees the database engine enforces directly. The classic distributed equivalent, two-phase commit (2PC), tries to extend that same guarantee across multiple databases: a coordinator asks every participant to prepare to commit, waits for all of them to confirm they can, then tells everyone to actually commit. It works — but it has properties that make it a poor fit for the independent, separately-owned services Week 17's gRPC work makes it easy to build.

  • Blocking — every participant holds its local locks from "prepare" until the coordinator's final decision arrives; if the coordinator or network is slow, every participant's resources stay locked, including rows unrelated services need.
  • A single coordinator is a new point of failure — if it crashes between "prepare" and "commit," participants can be left holding a decision they can't resolve on their own.
  • Tight coupling — every participating service has to support the same distributed transaction protocol and be simultaneously available for the transaction to complete at all, which works against the entire point of services that can deploy, fail, and scale independently.

The Saga pattern takes a different tradeoff entirely: instead of one atomic operation, a saga is a sequence of local transactions, each in its own service and its own database, where each step publishes an event that triggers the next. There's no cross-service lock held anywhere — the tradeoff is giving up strict consistency in favor of eventual consistency, and taking on the responsibility of undoing already-completed steps if a later one fails.

2. A Choreographed Saga with BullMQ

Consider placing an order that needs to reserve inventory and charge a payment, spanning three services. In a choreographed saga, each service reacts to events from the previous step and publishes its own — there's no central coordinator, just BullMQ jobs triggering each other through the Redis-backed queue from Week 12.

order-service/src/workers/orderWorker.ts
import { Worker } from "bullmq";

new Worker("order-events", async (job) => {
  if (job.name === "order.create") {
    const order = await saveOrder(job.data, "pending");
    await inventoryQueue.add("inventory.reserve", {
      orderId: order.id,
      items: job.data.items,
    });
  }
}, { connection: redisConnection });

new Worker("order-events", async (job) => {
  if (job.name === "order.cancel") {
    // the compensating action for a failed reservation
    await updateOrderStatus(job.data.orderId, "cancelled");
  }
}, { connection: redisConnection });
inventory-service/src/workers/inventoryWorker.ts
new Worker("inventory-events", async (job) => {
  if (job.name === "inventory.reserve") {
    const { orderId, items } = job.data;

    if (await tryReserve(items)) {
      await paymentQueue.add("payment.confirm", { orderId });   // next step
    } else {
      await orderQueue.add("order.cancel", { orderId });         // compensate
    }
  }
}, { connection: redisConnection });

Each worker only knows about the next job to enqueue, not the whole saga — the flow emerges from jobs triggering each other via queue.add(), the same queue mechanism from Week 12, rather than any single piece of code owning the full sequence. A failure at the inventory step enqueues order.cancel instead of the payment step, which is the compensating action that undoes the order-service's already-committed local transaction.

Every step needs a compensating action, planned up front

A saga's hard part isn't the happy path — it's designing what "undo" means for every step, before writing the forward logic. "Release reserved inventory" and "refund a charged payment" both need to be real, tested BullMQ jobs your service supports, not an afterthought bolted on once a failure in production reveals there's no way to undo step 2.

3. The Transactional Outbox Pattern

Section 2's chain depends on a subtle but critical guarantee: when saveOrder commits order to the database, the inventoryQueue.add() call right after it must also actually reach Redis — reliably, even if the process crashes between the two. Calling queue.add() directly after a Prisma write has a real failure window: if the process crashes right after the database commit but before the BullMQ call reaches Redis, the order is durable but the event is lost forever, and the saga stalls in a "pending" state nothing will ever advance.

The transactional outbox pattern closes that gap by writing the event to an outbox table in the same Prisma transaction as the actual business change — both succeed or both roll back together, guaranteed by the single database's own ACID properties, the same guarantee this whole lesson opened by saying doesn't extend across services.

writing the business change and the event atomically
async function createOrder(data: CreateOrderInput) {
  return prisma.$transaction(async (tx) => {
    const order = await tx.order.create({ data: { ...data, status: "pending" } });

    await tx.outboxEvent.create({
      data: {
        aggregateType: "Order",
        aggregateId: order.id,
        eventType: "order.create",
        payload: JSON.stringify({ orderId: order.id, items: data.items }),
      },
    });

    return order;   // commits BOTH rows together, or neither
  });
}

A separate poller — itself a scheduled BullMQ job — then reads unpublished rows from the outbox table and enqueues them, marking each one published only after a confirmed enqueue:

a simple polling publisher, itself a repeatable BullMQ job
new Worker("outbox-poller", async () => {
  const pending = await prisma.outboxEvent.findMany({ where: { published: false } });

  for (const event of pending) {
    await orderQueue.add(event.eventType, JSON.parse(event.payload));
    await prisma.outboxEvent.update({
      where: { id: event.id },
      data: { published: true },
    });
  }
}, { connection: redisConnection });

await outboxPollerQueue.add("poll", {}, { repeat: { every: 500 } });

This design accepts at-least-once delivery rather than exactly-once — a crash between enqueueing and marking published can cause a duplicate — which is why every worker in a saga needs to be idempotent: processing the same event twice must produce the same end state as processing it once, typically by checking whether that event's ID has already been handled before acting on it.

The outbox table trades a small amount of latency for a real reliability guarantee

Events aren't enqueued the instant the local transaction commits — they're enqueued on the next poll cycle, adding up to the poll interval's worth of latency. That's a deliberate, worthwhile trade: the alternative, enqueueing directly inside the request path, can silently lose events on a crash, which is a correctness bug a saga can't recover from on its own.

4. Hands-on Exercise

Hands-on

Build a choreographed saga with a transactional outbox and a deliberate failure path

Extend a second small service with an order-placement saga that can genuinely fail and compensate.

Requirements:

  1. Add an outbox table to your order service, and write events to it in the same Prisma transaction as the business change, per Section 3.
  2. Build a repeatable BullMQ job that polls unpublished outbox rows and enqueues them, marking each published only after a confirmed enqueue.
  3. Implement a choreographed saga: order creation triggers an inventory reservation attempt, which enqueues either a success or failure follow-up job.
  4. Implement the compensating action for the failure path — the order service transitioning a pending order to cancelled when the reservation fails.
  5. Make your event-handling worker idempotent: process the same event twice (simulate an outbox duplicate) and confirm the end state is identical to processing it once.
Hint

Track processed event IDs in a small dedicated table with a unique constraint, and check it before acting on an incoming event — that's the simplest reliable idempotency mechanism, and it's the same "have I seen this before" check regardless of which event type you're consuming.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why does two-phase commit's "blocking" property make it a poor fit for independently-owned microservices?

Every participant holds its local locks from the "prepare" phase until the coordinator's final commit decision arrives, which means one slow or unavailable participant — or the coordinator itself — can leave every other participant's resources locked, including rows completely unrelated services need. That contradicts the whole premise of services that are supposed to deploy, scale, and fail independently of one another.

Q2

In the choreographed saga above, how does the overall flow emerge without any single piece of code owning the full sequence?

Each BullMQ worker only knows the next job to enqueue given its own outcome — creating an order enqueues an inventory reservation job, which enqueues either a payment confirmation or the compensating cancellation. The full saga's behavior is the emergent result of each worker's local decision about what to enqueue next, not a single orchestrating function that reads through the whole sequence.

Q3

Why does the transactional outbox pattern require workers in the saga to be idempotent?

The outbox pattern guarantees at-least-once delivery, not exactly-once — a crash between successfully enqueueing an event and marking it published in the outbox table can cause the same event to be enqueued again on the next poll cycle. Workers have to treat processing the same event twice as safe, typically by tracking which event IDs have already been handled, or the duplicate would cause the same side effect (like reserving inventory) to happen twice.