Transactional Outbox Pattern
The atomicity problem you hit the moment a service writes to a DB AND publishes a message: how do you guarantee both happen, or neither? The transactional outbox is the standard answer.
The dual-write problem
# WRONG — dual write, not atomic
def place_order(order):
db.commit_order(order) # step 1
kafka.publish("OrderPlaced", order) # step 2
Failure cases:
- Step 1 succeeds, step 2 fails → DB has the order, no event published. Downstream consumers never know.
- Step 1 succeeds, step 2 succeeds, then step 1 transaction rolls back (e.g., a later constraint) → event published, no order.
- Step 2 partially succeeds (broker accepted but didn’t replicate) → maybe published, maybe not. You don’t know.
No combination of try/except saves you. There’s no 2PC across a database and a broker.
The outbox pattern
Write the event into an outbox table in the same DB transaction as the business change. A separate relay reads the outbox and publishes.
+--------------+ +-----------+ +-------+
| place_order +----->| Postgres | | Kafka |
| TX: | | orders | | |
| INS orders | | outbox +-->[relay]-->topic
| INS outbox | +-----------+ +-------+
| COMMIT |
+--------------+
BEGIN;
INSERT INTO orders (id, ...) VALUES (...);
INSERT INTO outbox (aggregate, event_type, payload, created_at)
VALUES ('order', 'OrderPlaced', :json, now());
COMMIT;
Either both INSERTs happen or neither. The DB transaction is the atomicity unit.
The relay process polls:
SELECT id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED; -- so multiple relay replicas don't collide
Publishes:
for row in rows:
kafka.publish(row.event_type, row.payload, key=row.aggregate_id)
db.execute("UPDATE outbox SET published_at = now() WHERE id = :id", id=row.id)
Delivery is at-least-once — the relay can crash between publish and update. The next iteration may publish the same row again. Consumers must be idempotent (see 10_message_queues/celery/04_idempotency.md).
SQLAlchemy implementation
class Outbox(Base):
__tablename__ = "outbox"
id = Column(BigInteger, primary_key=True, autoincrement=True)
aggregate_id = Column(String, nullable=False)
event_type = Column(String, nullable=False)
payload = Column(JSONB, nullable=False)
created_at = Column(DateTime(timezone=True), default=func.now())
published_at = Column(DateTime(timezone=True), nullable=True)
# In application code
with session.begin():
order = Order(...)
session.add(order)
session.flush() # populates order.id
session.add(Outbox(
aggregate_id=str(order.id),
event_type="OrderPlaced",
payload={"order_id": order.id, "user_id": order.user_id, "total": order.total},
))
The relay loop
import time
def relay():
while True:
with session.begin():
rows = session.execute(
select(Outbox).where(Outbox.published_at.is_(None))
.order_by(Outbox.id).limit(100)
.with_for_update(skip_locked=True)
).scalars().all()
if not rows:
time.sleep(0.1)
continue
for row in rows:
kafka.send(row.event_type, value=row.payload, key=row.aggregate_id).get()
row.published_at = datetime.now(timezone.utc)
Notes:
FOR UPDATE SKIP LOCKEDlets multiple relay replicas run safely; each picks distinct rows.kafka.send(...).get()waits for the broker ack. Important — without it, publish may not have committed when you mark the row published.- Batch publish + batch update for throughput.
Retention
The outbox grows unbounded. Cleanup:
DELETE FROM outbox WHERE published_at < now() - INTERVAL '7 days';
Run periodically (Celery Beat, cron). Keep enough history to debug “did this event fire?” but don’t let the table grow forever.
Variant: CDC (Change Data Capture)
Instead of a relay process polling the outbox, use Debezium to read the Postgres WAL directly. Every row inserted into outbox becomes a Kafka message automatically.
Postgres WAL → Debezium → Kafka topic
Trade-offs:
- No relay process to maintain.
- Lower latency (Debezium streams, no polling).
- Survives outages well.
- Operational complexity of running Debezium + Kafka Connect.
- Logical decoding requires
wal_level = logicalon Postgres.
For Postgres + Kafka shops, Debezium-based CDC is the modern preferred approach.
You can even skip the outbox table and have Debezium read directly from your business tables. But: this couples your event schema to your DB schema, leaks internal structure, and makes it hard to evolve. Outbox + Debezium reading the outbox table is the cleaner pattern.
Variant: at-least-once publish without outbox
For scenarios where you can afford rare duplicate events AND your downstream is idempotent:
# Subscribe to DB change feed (Postgres LISTEN/NOTIFY)
def on_order_inserted(notification):
kafka.publish("OrderPlaced", notification.payload)
db.add_listener("orders_changed", on_order_inserted)
Or transactional callbacks:
@event.listens_for(session, "after_commit")
def publish_after_commit(session):
for obj in session.info.get("pending_events", []):
kafka.publish(...)
These are less safe (no replay; lose events on consumer crash) but simpler. Don’t use for anything load-bearing.
Ordering
If consumers care about event order per-aggregate (orders for the same user_id in order):
- Outbox
id(auto-increment) preserves total order globally. - Use
aggregate_idas Kafka key so all events for the same aggregate go to the same partition (preserves per-key order). - Single-relay process publishes in
idorder; multi-relay needs care.
Common gotchas
- Forgetting to mark
published_atafter publish. Infinite republish. - Publishing inside the business transaction. Destroys the atomicity benefit — if commit fails, you’ve already published.
- No retention policy. Outbox grows forever, hurts performance.
- Relay process is single-instance and dies. Events stop flowing silently. HA the relay or use advisory-lock-based singleton.
- Wrong aggregate ID as Kafka key. Same logical entity hashes to different partitions; ordering breaks.
- Forgetting to wait for broker ack. Marking row published before broker confirms = lost events on broker crash.
Comparison to event sourcing
Event sourcing: events ARE the source of truth; state is rebuilt by replay. Outbox: state in DB is the truth; events are derived.
They overlap but solve different problems:
- Outbox = “publish reliably from a state-based system.”
- Event sourcing = “make events the system.”
You can outbox events from an event-sourced system. They compose.
When to skip the outbox
- Single-database transactions only (no events crossing services).
- Eventually consistent and you’re OK losing rare events.
- You have a workflow engine (Temporal) handling the state — the engine’s history is the outbox.
Interview angle
- “What’s the dual-write problem and how do you solve it?” — atomicity between two systems (DB + broker) without 2PC. Solution: transactional outbox. Write the event to an outbox table in the same DB transaction as the business change; a separate relay polls the outbox and publishes. Delivery is at-least-once; consumers must be idempotent.
- “Why not just publish, then write to DB?” — if the publish succeeds and the DB write fails, you’ve published a phantom event. Outbox makes the DB write authoritative.
- “How do you avoid duplicate events from the outbox?” — you don’t (delivery is at-least-once). Downstream consumers dedupe by message ID. The outbox guarantees at-least-once; consumer idempotency guarantees exactly-once processing.
- “What’s CDC and how does it relate to outbox?” — Change Data Capture (Debezium) reads the Postgres WAL and emits row changes as Kafka events. Skips the relay process. Combine with outbox table (Debezium reads outbox) for clean event schemas.
- “How do you preserve event ordering?” — outbox.id (auto-increment) is global order. Use aggregate_id as the Kafka key so all events for the same entity go to the same partition (preserves per-aggregate order even with multi-partition consumers).
- “Why use outbox vs publishing on commit hooks?” — outbox survives broker outages (events accumulate in DB until relay can publish), supports replay (DB has the events), separates business logic from messaging. Commit hooks lose events on broker downtime.