Tied to Mission: Backend Depth — Lesson 14 made single retries safe; Lesson 15 made reads tolerate lag. This is where those meet: how to make a database write and a message to another system atomic, when no single transaction can span them.
You order something. The database row says the order exists — you can see it in the UI. But the confirmation email never arrives, and the analytics team's "orders" chart never moves. The order service wrote to its database and then… something between the write and the publish didn't happen. The order exists; the event doesn't. Two systems, one logical operation, and no atomicity between them.
A "create order" operation touches more than the database: it should also emit an event so the email service, the analytics pipeline, and the inventory service can react. That's a write to your DB and a write to a message broker — two different systems. The naive code does them in sequence:
with db.transaction():
insert_order(...) # 1. write to the database
publish("order_created") # 2. write to the broker
What happens if the process crashes between step 1 and step 2? The order exists, the event never fires — the email is never sent. What happens if you reverse the order and crash between those steps? The event fires for an order that doesn't exist — the email service confirms a phantom order, analytics counts a sale that never happened. Neither order is safe. The crash window just moves from "lost event" to "phantom event."
And you can't fix it by "wrapping it in a transaction," because a transaction belongs to one database. Your broker is a different system — it has no idea your DB transaction is open. Two-phase commit (2PC) exists to make heterogeneous systems commit together, but it's rarely the answer: it holds locks on every participant until the coordinator decides, and if the coordinator crashes mid-protocol the participants are stuck. Production systems mostly avoid it for exactly this reason. Lesson 2's move is the template here: the fix lives in the data layer, not in more careful app code.
The trick — this is the transactional outbox: write the event to a table in your own database, inside the same transaction as the business write. Now the database transaction — the atomicity you already trust — covers both the order and its event:
with db.transaction():
insert_order(...)
insert_outbox(event_type='order_created', payload=...) # same tx
The crash window is gone: if the process dies before COMMIT, both the order and the event row vanish together (rollback). If it dies after COMMIT, both are durable — and the relay, a separate process doing a separate job, will find the event row and publish it. The database transaction is now the atomicity boundary for both systems' writes.
The relay does three things, in order:
Three failure modes matter, and each has a name you already know:
The relay is usually one of two things: a polling loop in your own service ("every 100ms, select unpublished rows, publish, mark") — simple, portable, adds a small publish delay — or change data capture (CDC, e.g. Debezium), which reads the database's own write-ahead log and turns every committed change into an event. CDC isn't a second reader competing with your app; it reads the same WAL the database itself uses to be durable (the machinery from Lessons 8 and 11). The outbox table makes CDC trivial to set up, because every event is just a normal committed row. One subtlety worth knowing: the outbox table is an ordinary table, so it's replicated and laggy like any other (Lesson 15) — reading it from a replica means your relay publishes with lag.
The outbox solves "one database + one event." But a real order flow touches the order service, payment service, inventory service, shipping service — each with its own database, possibly owned by a different team. There is no transaction that spans them, and 2PC won't save you. This is what sagas are for.
A saga breaks the business process into steps, each with its own local transaction (committed for real), and gives every step with side effects a compensating transaction that undoes it. If step N fails, you run the compensations for steps N−1 down to 1, in reverse. The payment step's compensation is a refund. The inventory step's compensation is returning the stock. The order step's compensation is cancelling the order.
Two shapes exist:
The part that makes sagas hard is the failure path, and every piece of it reuses a previous lesson:
A saga is sometimes described as "a transaction without atomicity." More precisely: a normal transaction guarantees atomicity by not committing until everything is ready. A saga achieves its guarantee by running the undo when something goes wrong. That's why it works across systems and long-running flows — no locks are held across services, no coordinator freezes participants — and why the compensation path has to be designed first, not bolted on after.
And the two compose: each saga step's "step succeeded" event is written to that service's outbox in the same transaction as the step's own write. The outbox is where sagas' events come from.
Build a 20-line outbox with SQLite — no server needed. Create two tables, orders and outbox. First reproduce the bug: insert into orders, commit, and don't write the outbox row (simulate the crash by skipping the publish). The order exists; there is no event — a permanent state. Now redo it correctly: BEGIN; INSERT INTO orders; INSERT INTO outbox; COMMIT;. Write a relay loop that selects unpublished rows, prints them (the "publish"), and marks them done — then kill the relay between select and mark and restart it: the row gets published twice. That's at-least-once in miniature; add a consumer-side processed_events table with a unique constraint on the event id (Lesson 14's move) and the duplicate disappears. For the saga half: simulate a two-step flow (charge + ship) where step 2 fails, and write the compensation (refund) that runs in reverse — then make the compensation fail once, and see why it has to be retried with backoff (Lesson 7).
Designing Data-Intensive Applications, Chapter 11, "Stream Processing" — the dual-write problem and the outbox pattern (see "Keeping Systems in Sync"), plus Chapter 9 on why distributed transactions are hard. Chris Richardson's saga pattern is the canonical practitioner treatment, including choreography vs. orchestration and the compensation table. Pat Helland's "Life beyond Distributed Transactions: An Apostate's Opinion" is the original argument for why sagas and entities beat 2PC at scale. For the relay in production form: Debezium's outbox pattern documentation.
The outbox produces events; the next lesson is about the system those events land in — the log, and why "ordering within a partition" is the idea everything else builds on — Lesson 17.