Updating an order and publishing its event are separate actions. If the database commits and the broker fails, an order can exist without a notification. If the notification leaves first and the database aborts, another service can act on a fact that never committed.

An outbox records the change and pending event inside the same local transaction. It solves that dual write at the source; it does not turn the network, broker, and consumer into participants in one transaction. The relay can still repeat a publication and a projection can lag.

The event belongs to the commit

When an order changes to confirmed, the transaction stores an outbox row with event_id, entity identity, type, version, and payload. Identity stays stable through retries. The event represents a committed fact and contains what its consumers need, without indiscriminately exporting private table fields.

The 2PC and Saga note explains the local boundary. Here the focus is what happens afterward: claiming work, publishing, recognizing duplicates, and rebuilding views. The critical rule is that no relevant write path changes the business state without recording the event its contract requires.

A relay with durable recovery

A polling relay selects pending events. Several workers can claim rows using brief locks; SKIP LOCKED avoids waiting for already claimed rows, but does not provide a global snapshot or guarantee order among workers. The claim and its owner are stored in a short transaction.

If the lock is released before calling the broker, durable state is needed: a claim deadline, attempt, and ownership version. A worker failure allows event recovery; a late confirmation only changes a claim it still owns. Database locks are not retained throughout a network wait.

After a valid publication acknowledgement, progress is recorded. The broker’s response can be lost, or the relay can crash before storing that record. The next attempt keeps event_id and can publish again. No ordering of these two steps removes uncertainty by itself.

Polling and CDC are different mechanisms

CDC observes committed changes through the database log and transforms them into events. An outbox connector can read those inserts without the application continually querying the table. Log position and business identity remain different objects.

The choice depends on permissions, log retention, connector operations, and required latency. A stopped consumer can increase lag and disk usage associated with retained data; an unrecoverable position requires resynchronization under the contract. CDC does not automatically identify which technical change should become a public event or repair incompatible schemas.

---
config:
  look: handDrawn
---
flowchart TD
    app["Business operation"] --> database[("Local commit<br/>State + event in outbox")]
    database --> relay["Relay<br/>Durable claim or CDC"]
    relay -->|"Event with stable identity"| broker["Broker<br/>Publication may repeat"]
    broker -->|"Publication acknowledgement"| relay
    relay --> progress["Record progress<br/>May require recovery"]
    broker --> consumer["Consumer<br/>Deduplicate and validate version"]
    consumer --> view[("View + local progress<br/>Same transaction when applicable")]

The arrows do not promise a joint acknowledgement across the database, broker, and view. Each boundary states what persisted and how it recovers an unknown outcome.

Ordering and deduplication are not equivalent

Idempotency recognizes a repeated event. Ordering also needs a per-entity sequence or version and a compatible partitioning and processing strategy. Claiming a row first does not guarantee finishing its publication first.

An inventory projection can receive version eight before seven. If each event contains complete state, a conditional update can retain the latest version. If it represents increments or dependent transitions, arbitrarily discarding the earlier version can lose work: the projection must detect the gap, recover the sequence, or rebuild from an authoritative source.

The broker does not guarantee global ordering across entities either. A feed, search index, and balance can need different contracts. Access patterns help define the derived view and which data retains authority.

Rebuild a view without repeating external effects

Reconstruction combines a coherent snapshot with a continuation position, or replays sufficient history. The contract defines how to avoid gaps between snapshot and events and resume after failure. Copying current rows and starting consumption “from now” is insufficient while writes arrive.

A parallel view can be built, subsequent changes applied, and versions, counts, and invariants checked before switching readers. The transition must coordinate which version each reader serves and retain recovery. Replaying events to build a view does not authorize charging again, sending emails, or executing business commands.

Retention determines what can be rebuilt. If history no longer exists, another snapshot and reconciliation are needed. The ticketing case retains authoritative inventory even while Elasticsearch or the seating map lags.

Retention, invalid messages, and verification

An outbox cannot grow indefinitely. Pending events, published events, and those retained for audit or replay are distinguished; cleanup respects consumers and the recovery window. An incompatible event needs diagnosis and an explicit strategy, rather than infinite retries or deleting its record to make the error disappear.

Tests stop a relay after publishing but before marking progress, duplicate events, reverse versions, and rebuild while entities change. They also expire claims and deliver late confirmations. Pending age, lag, duplicates, gaps, and divergence between authority and projection are observed.

An outbox helps when the business needs to preserve publication intent with its change. Its success is measured by recovery and correct views under failure rather than only events per second.

References: PostgreSQL transactions, Debezium Outbox Event Router, and the scope of Kafka delivery guarantees.