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.
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 });
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.
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.
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:
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.
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
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:
- Add an
outboxtable to your order service, and write events to it in the same Prisma transaction as the business change, per Section 3. - Build a repeatable BullMQ job that polls unpublished outbox rows and enqueues them, marking each published only after a confirmed enqueue.
- Implement a choreographed saga: order creation triggers an inventory reservation attempt, which enqueues either a success or failure follow-up job.
- Implement the compensating action for the failure path — the order service transitioning a pending order to cancelled when the reservation fails.
- 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.
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?
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?
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?
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.