Lesson 70 of 129
Transactional Outbox and CDC
A service often must change its database and publish an event about the change, for example saving an order and publishing OrderPlaced. The database and the broker are separate systems with no shared transaction. The transactional outbox pattern makes the state change and the intent to publish one atomic database transaction, and a separate process delivers the message afterward.
The dual-write problem
Naive approaches fail:
| Approach | Failure |
|---|---|
| Commit DB, then publish | Crash after commit and before publish: state changed, event lost |
| Publish, then commit DB | Crash or rollback after publish: event announces a change that did not happen |
| Publish before the DB commit, inside the transaction block | Publish succeeds, then the commit fails. The broker is not part of the transaction |
Two-phase commit across a database and a broker is unsupported by most brokers and reduces availability (see distributed transactions).
Outbox pattern
- In one local database transaction, the service writes its business data and inserts a row in an outbox table in the same database.
- A separate relay reads new outbox rows and publishes them to the broker.
- After the broker acknowledges, the relay marks the row as sent or deletes it.
Because both writes share one ACID transaction, either both happen or neither does.
flowchart LR
S["Service"] -->|"one transaction"| DB[("DB: orders + outbox")]
DB --> R["Relay"]
R --> B["Broker"]
B --> C["Consumers"]A typical outbox row holds: an event ID (a UUID that consumers use for deduplication), the aggregate type and ID (the aggregate ID becomes the message key), the event type, a payload (JSON or binary), and a timestamp. A polling relay also needs a monotonically increasing column (seq, an auto-increment value) and a sent flag, or another way to track progress.
BEGIN;
INSERT INTO orders (id, customer_id, total, status) VALUES (42, 7, 99.00, 'PENDING');
INSERT INTO outbox (id, aggregate_id, type, payload, created_at)
VALUES ('7d1c0e52-0f0b-4c55-9a51-3f2f6a1b9c10', 42, 'OrderPlaced', '{"orderId":42,"total":99.00}', now());
COMMIT;If the transaction rolls back, neither the order nor the event exists. If it commits, the event is durably stored and will be published even if the service crashes immediately afterward, because the relay reads it from the database.
Relay implementations
Polling publisher
The relay queries the outbox at intervals, for example SELECT ... WHERE sent = false ORDER BY seq LIMIT n, publishes each row, and marks it sent.
- Simple, works with any database.
- Adds polling load and latency bounded by the poll interval.
- Scaling with several relays requires partitioning the rows or row locking (for example,
FOR UPDATE SKIP LOCKEDin PostgreSQL) to avoid duplicate work and reordering. - Old rows must be deleted or archived so the table stays small.
Log-based change data capture
Change data capture (CDC) reads the database's transaction log (write-ahead log) and emits a stream of row changes. The relay does not query the outbox table. Instead, a CDC connector tails the log, sees each insert into the outbox, and publishes it.
Debezium is a widely used CDC platform, typically run on Kafka Connect, with connectors for PostgreSQL (logical decoding), MySQL (binlog), and others. It provides an outbox event router that turns outbox rows into messages on topics by aggregate type.
- Low latency, no polling load on tables.
- Log order gives a total order of committed changes.
- Requires database configuration (logical replication settings, permissions) and operating the connector.
- The connector tracks its position in the log (offset), so it resumes after a restart.
- Outbox rows can be deleted right after insertion, since the insert is already in the log.
- Failure mode: if the connector is down for a long time, PostgreSQL retains the write-ahead log that the connector's replication slot has not yet consumed, and disk usage on the database server grows until the connector resumes or the slot is dropped.
- The connector stores its log position periodically, not per event, so after a crash it can re-emit some events. CDC delivery is therefore also at-least-once.
CDC is also used without an outbox to stream table changes to search indexes, caches, and warehouses, but exposing raw table changes leaks the internal schema. Outbox events form an explicit contract.
Delivery semantics
The relay can crash after publishing and before marking the row sent, and then publish again. Delivery is therefore at-least-once. Consumers must be idempotent or deduplicate by event ID (see idempotent consumers and deduplication and delivery semantics).
Ordering
- Auto-increment values are assigned when the row is inserted, not when the transaction commits, so concurrent transactions can commit out of sequence order. Example: transaction A inserts
seq10, transaction B insertsseq11 and commits, the relay reads up to 11 and advances its cursor to 11, then A commits. Row 10 is now below the cursor and is never published. Mitigations are to poll on asentflag instead of a cursor, or to read from the transaction log, which lists changes in commit order. - To preserve per-entity order in the broker, use the aggregate ID as the message key so all events for one aggregate go to the same partition (see Kafka internals). Order holds only if the relay publishes one aggregate's events sequentially and a retry of a failed publish cannot overtake later events (for Kafka producers, idempotent producer settings or one in-flight request address this).
- Global order across aggregates is generally not needed and is not guaranteed across partitions.
Inbox and the reverse direction
The outbox solves the producer side. The consumer side has a similar problem: processing a message and recording that it was processed must be atomic. This is the inbox pattern (see idempotent consumers and deduplication).
Trade-offs
- Extra table, extra component (relay or CDC connector) to operate and monitor.
- Event publication is delayed until the relay runs. The design gives eventual, not immediate, delivery.
- Outbox growth: purge sent rows.
- Requires the service's database to support transactions. For stores without multi-item transactions, alternatives are event sourcing (the event store is the source of truth; see event sourcing and CQRS) or stores with their own change streams.
Interview questions
- What problem does the outbox solve? Atomically updating local state and publishing a message, avoiding lost or phantom events when the database commit and the broker publish are separate.
- Why is delivery at-least-once and not exactly-once? The relay can fail between publishing and recording that it published, so it republishes after restart. Consumers therefore deduplicate.
- Polling vs log-based CDC? Polling is simple and database-agnostic but adds load and latency and can mishandle ordering. Log-based CDC has low latency and commit ordering but needs database-specific setup and a connector.
- How do you keep events of one entity ordered? Use the entity ID as the partition key so its events land in one partition, and publish in commit order.
