Event-Driven Architecture and the Outbox Pattern

ArchitectureEvent-DrivenKafkaDistributed Systems

Every event-driven system eventually runs into the same quiet bug: an order gets saved to the database, but the OrderPlaced event that was supposed to trigger the email, the inventory decrement, and the analytics pipeline never fires. Or the reverse — the event fires, downstream systems react, and then the database transaction rolls back, so everyone acted on an order that doesn't exist. This is the dual-write problem, and the outbox pattern is the standard cure.

The root cause is that a database and a message broker are two independent systems with no shared transaction. You cannot atomically "commit to Postgres and publish to Kafka." Something has to give, and the outbox pattern gives you a way to make the event a byproduct of the database commit instead of a second, unreliable step.

Why you can't just publish after committing

The naive code looks reasonable:

def place_order(order):
    db.insert(order)              # 1. write to database
    kafka.publish("orders", event)  # 2. publish event

Now walk the failure modes. If the process dies between line 1 and line 2, the order exists but no event was published — silent data loss that surfaces days later as "why didn't this customer get their confirmation?" If you swap the order and publish first, a database failure on line 1 leaves you having announced an order that was never saved. Wrapping both in a "transaction" doesn't help, because Kafka isn't enrolled in your database's transaction.

Retries don't save you either. If the publish fails and you retry, you might publish twice. If the database commit succeeds but the acknowledgement is lost, you don't know whether to retry. There is no ordering of these two operations that is both safe and simple. The trick is to stop treating them as two writes to two systems.

The outbox: one atomic write

The outbox pattern collapses the two writes into one. Instead of publishing to the broker inside your business transaction, you insert a row into an outbox table in the same transaction as your business data:

BEGIN;
INSERT INTO orders (id, customer_id, total, status)
VALUES ('ord_123', 'cust_9', 4200, 'placed');

INSERT INTO outbox (id, aggregate_id, type, payload, created_at)
VALUES ('evt_456', 'ord_123', 'OrderPlaced',
        '{"orderId":"ord_123","total":4200}', now());
COMMIT;

Because both inserts are in one local transaction, they commit together or not at all. If the order is saved, the event is saved. If the transaction rolls back, neither exists. The atomicity you couldn't get across two systems, you get for free within one database.

A separate process — the relay or message relay — then reads unpublished rows from the outbox, publishes them to Kafka, and marks them sent:

while True:
    rows = db.query(
        "SELECT * FROM outbox WHERE published_at IS NULL "
        "ORDER BY created_at LIMIT 100"
    )
    for row in rows:
        kafka.publish(topic_for(row.type), row.payload, key=row.aggregate_id)
        db.execute(
            "UPDATE outbox SET published_at = now() WHERE id = %s", row.id
        )

The business transaction is fast and never touches the broker. Delivery becomes an independent, retryable concern handled by the relay.

At-least-once, not exactly-once

The outbox guarantees no event is ever lost, but it does not give you exactly-once delivery, and pretending otherwise is where people get burned. Picture the relay publishing a row to Kafka successfully and then crashing before the UPDATE published_at commits. On restart it sees the row as unpublished and publishes it again. That's at-least-once: every event is delivered one or more times.

This pushes a requirement onto consumers: they must be idempotent. A consumer processing OrderPlaced twice must produce the same result as processing it once. The usual approach is to track processed event ids and skip duplicates, which ties directly into broader idempotency techniques for distributed systems. Put the event id in the payload, have the consumer record it after processing, and check it on the way in.

Polling versus change data capture

The relay I sketched polls the outbox table. Polling is simple, easy to reason about, and fine for moderate throughput. Its downsides are latency (bounded by the poll interval) and the load of repeatedly querying for unpublished rows.

The higher-throughput alternative is change data capture (CDC): instead of polling, you tail the database's write-ahead log. A tool like Debezium reads Postgres's WAL, notices inserts into the outbox table, and streams them to Kafka with low latency and no polling load on the database.

Approach Latency DB load Complexity
Polling relay poll interval (e.g. 1s) repeated queries low
CDC (Debezium) near-real-time reads WAL higher (extra infra)

I reach for polling first and only move to CDC when either the latency or the query load actually becomes a problem. The pattern is identical either way; CDC just changes how rows leave the table.

Ordering, cleanup, and other sharp edges

A few operational details decide whether an outbox implementation stays healthy:

When to reach for it

Use the outbox pattern whenever a service must change its own state and tell the outside world about that change, and correctness depends on those two things staying consistent. Order placement, payment state transitions, and the kind of transaction lifecycle I dealt with on an EV charging platform all fit — anywhere a lost or phantom event turns into a billing dispute or a stuck workflow.

It's not free. You take on an extra table, a relay process, and a hard requirement that consumers be idempotent. But compared to the alternative — a distributed system that occasionally, unreproducibly, loses events — that cost is a bargain. The dual-write problem doesn't go away because you ignore it; it just waits.

Resources

Frequently asked questions

What is the dual-write problem?

It's the failure that happens when a service must update its database and publish an event as two separate operations. If the process crashes between them, you either persisted state with no event or published an event that never actually happened. There's no atomic way to do both across two systems.

How does the outbox pattern solve it?

You write the event into an 'outbox' table in the same local database transaction that changes your business data. Because both writes commit atomically, the event can never be lost. A separate relay process then reads the outbox and publishes to the message broker.

Do consumers still need to handle duplicates with an outbox?

Yes. The outbox guarantees at-least-once delivery, not exactly-once. The relay can crash after publishing but before marking a row as sent, causing a re-publish, so consumers must be idempotent — typically by tracking processed event ids.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →