Backend / Architecture & design / 16_transactional_outbox.md

Transactional Outbox Pattern

Updated 6 interview angles 5 min read source
On this page12
  1. The dual-write problem
  2. The outbox pattern
  3. SQLAlchemy implementation
  4. The relay loop
  5. Retention
  6. Variant: CDC (Change Data Capture)
  7. Variant: at-least-once publish without outbox
  8. Ordering
  9. Common gotchas
  10. Comparison to event sourcing
  11. When to skip the outbox
  12. Interview angle

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

python
# 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.

text
+--------------+      +-----------+      +-------+
| place_order  +----->| Postgres  |      | Kafka |
| TX:          |      |  orders   |      |       |
|  INS orders  |      |  outbox   +-->[relay]-->topic
|  INS outbox  |      +-----------+      +-------+
| COMMIT       |
+--------------+
sql
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:

sql
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:

python
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 Celery Task Idempotency).

SQLAlchemy implementation

python
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)
    # populates order.id
    session.flush()
    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

python
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 LOCKED lets 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:

sql
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.

text
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 = logical on 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:

python
# 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:

python
@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_id as Kafka key so all events for the same aggregate go to the same partition (preserves per-key order).
  • Single-relay process publishes in id order; multi-relay needs care.

Common gotchas

  • Forgetting to mark published_at after 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 6

  • “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.