Event-Driven Architecture · lesson 7 of 10
Beginner
3Intermediate
6- 4 Event Sourcing: Snapshots So Replay Doesn't Get Slower Forever
- 5 CQRS: Separate Write and Read Models, and the Lag Between Them
- 6 The Dual-Write Problem: A DB Commit and a Publish Can Fail Independently
- 7 Transactional Outbox: Committing the Event With the Data
- 8 Idempotent Consumers: Surviving a Duplicate Delivery
- 9 Dead-Letter Queues: What Happens When Retries Keep Failing
Also part of Event-Driven Architecture
6- + Saga Pattern: Orchestrating a Multi-Step Transaction from Microservices
- + Saga Pattern: Real Compensation When a Step Fails from Microservices
- + At-Most-Once, At-Least-Once, and Exactly-Once from Kafka
- + Serialization and the Schema Registry from Kafka
- + Pub/Sub: Channels, Patterns, and the Message That Never Arrives from Redis
- + Incremental Loads and Change Data Capture from Data Engineering
Also part of: Microservices
Transactional Outbox: Committing the Event With the Data
Writing the event to the same database transaction as the data closes the dual-write gap; a relay then publishes it, at least once.
By SkillByExample · Published
Committing an order and its outbox row happens inside one database transaction — the same all-or-nothing guarantee that protects a single row now protects the event alongside it.
The event survives even when the broker is down
import sqlite3
class BrokerDown(Exception):
pass
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER)")
conn.execute("CREATE TABLE outbox (id INTEGER PRIMARY KEY, payload TEXT, published INTEGER DEFAULT 0)")
conn.commit()
def place_order(order_id, total):
conn.execute("INSERT INTO orders VALUES (?, ?)", (order_id, total))
conn.execute("INSERT INTO outbox (payload, published) VALUES (?, 0)",
(f"OrderPlaced:{order_id}:{total}",))
conn.commit()
print(f" order {order_id} and its outbox row committed together, in one transaction")
place_order(1, 75)
def relay(publish_fn):
rows = conn.execute("SELECT id, payload FROM outbox WHERE published = 0").fetchall()
for outbox_id, payload in rows:
try:
publish_fn(payload)
conn.execute("UPDATE outbox SET published = 1 WHERE id = ?", (outbox_id,))
conn.commit()
print(f" relayed and marked published: {payload}")
except BrokerDown as e:
print(f" relay failed for {payload}, will retry next run: {e}")
def broker_down(payload):
raise BrokerDown("message broker unreachable")
def broker_up(payload):
pass
print("First relay attempt, broker down:")
relay(broker_down)
unpublished = conn.execute("SELECT COUNT(*) FROM outbox WHERE published = 0").fetchone()[0]
print(f"Still unpublished: {unpublished}")
print("Second relay attempt, broker back up:")
relay(broker_up)
unpublished = conn.execute("SELECT COUNT(*) FROM outbox WHERE published = 0").fetchone()[0]
print(f"Still unpublished: {unpublished}")
$ python transactional_outbox.py
order 1 and its outbox row committed together, in one transaction
First relay attempt, broker down:
relay failed for OrderPlaced:1:75, will retry next run: message broker unreachable
Still unpublished: 1
Second relay attempt, broker back up:
relayed and marked published: OrderPlaced:1:75
Still unpublished: 0
Unlike the previous lesson’s naive version, a broker outage here loses nothing — the outbox row
stays at published = 0 for as long as the broker is down, and the relay simply tries again
whenever it next runs. The event was never at risk, because it was already durable in the same
transaction as the order itself.
Practice
1. Crash before the transaction commits, and confirm neither the order nor the outbox row survives.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER)")
conn.execute("CREATE TABLE outbox (id INTEGER PRIMARY KEY, payload TEXT, published INTEGER DEFAULT 0)")
conn.commit()
def place_order_crashes_before_commit(order_id, total):
conn.execute("INSERT INTO orders VALUES (?, ?)", (order_id, total))
conn.execute("INSERT INTO outbox (payload, published) VALUES (?, 0)",
(f"OrderPlaced:{order_id}:{total}",))
raise RuntimeError("process killed before commit()")
try:
place_order_crashes_before_commit(1, 50)
except RuntimeError as e:
print(f"Crashed: {e}")
conn.rollback()
orders = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
outbox = conn.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]
print(f"Orders after rollback: {orders}")
print(f"Outbox rows after rollback: {outbox}")
$ python crash_before_commit.py
Crashed: process killed before commit()
Orders after rollback: 0
Outbox rows after rollback: 0
This is the actual guarantee the pattern relies on: a crash before commit() leaves both
tables exactly as they were, so there’s never a state where the order exists without its
outbox row (or the reverse) — the two inserts share one atomic unit.
2. Crash the relay between publishing and marking the row published, and observe the duplicate delivery.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER)")
conn.execute("CREATE TABLE outbox (id INTEGER PRIMARY KEY, payload TEXT, published INTEGER DEFAULT 0)")
conn.commit()
conn.execute("INSERT INTO orders VALUES (1, 75)")
conn.execute("INSERT INTO outbox (payload, published) VALUES ('OrderPlaced:1:75', 0)")
conn.commit()
published_to_broker = []
def relay_crashes_after_publish():
rows = conn.execute("SELECT id, payload FROM outbox WHERE published = 0").fetchall()
for outbox_id, payload in rows:
published_to_broker.append(payload)
print(f" published: {payload}")
raise RuntimeError("relay process killed right here, before marking published")
try:
relay_crashes_after_publish()
except RuntimeError as e:
print(f"Relay crashed: {e}")
def relay(publish_fn):
rows = conn.execute("SELECT id, payload FROM outbox WHERE published = 0").fetchall()
for outbox_id, payload in rows:
publish_fn(payload)
conn.execute("UPDATE outbox SET published = 1 WHERE id = ?", (outbox_id,))
conn.commit()
def publish_ok(payload):
published_to_broker.append(payload)
print(f" published: {payload}")
print("Relay runs again after restart:")
relay(publish_ok)
print(f"Broker received this payload {published_to_broker.count('OrderPlaced:1:75')} time(s): {published_to_broker}")
$ python duplicate_delivery.py
published: OrderPlaced:1:75
Relay crashed: relay process killed right here, before marking published
Relay runs again after restart:
published: OrderPlaced:1:75
Broker received this payload 2 time(s): ['OrderPlaced:1:75', 'OrderPlaced:1:75']
The outbox row was still published = 0 when the relay restarted, because the crash happened
before the UPDATE. This is the “at least once” half of the tradeoff — the event is never
lost, but it can genuinely arrive twice, which the next lesson’s idempotent consumer is what
actually protects against.
3. Run the relay repeatedly against an empty outbox and confirm it's a safe no-op.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE outbox (id INTEGER PRIMARY KEY, payload TEXT, published INTEGER DEFAULT 0)")
conn.commit()
def relay(publish_fn):
rows = conn.execute("SELECT id, payload FROM outbox WHERE published = 0").fetchall()
for outbox_id, payload in rows:
publish_fn(payload)
conn.execute("UPDATE outbox SET published = 1 WHERE id = ?", (outbox_id,))
conn.commit()
return len(rows)
def publish_ok(payload):
print(f" published: {payload}")
for run in range(1, 4):
processed = relay(publish_ok)
print(f"Relay run {run}: processed {processed} row(s)")
$ python empty_outbox_noop.py
Relay run 1: processed 0 row(s)
Relay run 2: processed 0 row(s)
Relay run 3: processed 0 row(s)
Nothing about relay assumes there’s anything to process — an empty result from the SELECT
just means the for loop body never runs, which is why polling it on a fixed schedule is safe
even when there’s nothing new to relay most of the time.
4. Place three orders before running the relay once, and confirm all three get processed in a single pass.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER)")
conn.execute("CREATE TABLE outbox (id INTEGER PRIMARY KEY, payload TEXT, published INTEGER DEFAULT 0)")
conn.commit()
def place_order(order_id, total):
conn.execute("INSERT INTO orders VALUES (?, ?)", (order_id, total))
conn.execute("INSERT INTO outbox (payload, published) VALUES (?, 0)",
(f"OrderPlaced:{order_id}:{total}",))
conn.commit()
place_order(1, 75)
place_order(2, 20)
place_order(3, 40)
def relay(publish_fn):
rows = conn.execute("SELECT id, payload FROM outbox WHERE published = 0").fetchall()
for outbox_id, payload in rows:
publish_fn(payload)
conn.execute("UPDATE outbox SET published = 1 WHERE id = ?", (outbox_id,))
conn.commit()
return len(rows)
def publish_ok(payload):
print(f" published: {payload}")
processed = relay(publish_ok)
print(f"Processed {processed} pending row(s) in one pass")
$ python batch_relay.py
published: OrderPlaced:1:75
published: OrderPlaced:2:20
published: OrderPlaced:3:40
Processed 3 pending row(s) in one pass
The relay doesn’t care how many orders piled up since its last run — it just processes whatever’s currently unpublished, which is what makes it safe to run on a fixed interval instead of needing to be woken up for every single write.