Why architecture matters here
The architecture matters because it converts an unsolvable cross-system atomicity problem into a solved single-system one. You cannot make a database commit and a message publish happen atomically without distributed transactions that few brokers support and that carry serious latency and availability costs. The outbox sidesteps that entirely: both the business write and the event write target the same database, so ordinary local transaction guarantees — the ones the database already provides — make them atomic. The hard part of the dual-write problem simply disappears by refusing to write to two systems at commit time.
It matters because it makes the system reliable under crashes and retries, which are the normal state of distributed operation. Once the event row is committed alongside the data, the event is durable: even if the service crashes immediately after, the relay will find the row and publish it later. The service can crash between commit and publish as often as it likes and no event is lost, because the source of truth for 'what must be published' is the committed database, not volatile process memory.
It matters because it draws a clean line between correctness and delivery semantics. The outbox guarantees the event is eventually published at least once; it does not guarantee exactly-once or strict global ordering for free. That is a feature: it forces the design to make consumers idempotent and to be explicit about ordering, which is exactly the discipline that keeps event-driven systems correct. Understanding that the outbox gives at-least-once — and building consumers accordingly — is what separates a robust deployment from one that quietly double-processes.
The architecture: every piece explained
The outbox table. This is an ordinary table in the service's own database holding one row per event to be published: an event id, the event type, the payload (often serialized JSON or Avro), a timestamp, and a status or 'sent' marker. It lives in the same database as the business tables precisely so it can participate in the same transaction. It is the durable, authoritative record of 'these events must reach the broker,' independent of any process staying alive.
The single local transaction. The service handler, within one database transaction, performs the business mutation (insert the order, debit the account) and inserts the corresponding event row into the outbox. The database commits both or neither. This atomicity is the entire point: after commit, it is impossible for the data to have changed without the event existing, or the event to exist without the data change — the two are welded together by the transaction.
The relay. A separate component moves committed outbox rows to the broker. There are two dominant strategies. A polling relay periodically queries the outbox for unsent rows, publishes each to the broker, and marks it sent. A change-data-capture (CDC) relay tails the database's transaction log (via tools like Debezium) and streams new outbox inserts to the broker without polling. CDC lowers latency and load; polling is simpler and needs no log access.
The broker and idempotent consumers. The relay publishes to a message broker (Kafka, SQS, RabbitMQ, Pub/Sub) with at-least-once semantics — a crash between 'published' and 'marked sent' means the row will be re-published on recovery. Consumers must therefore dedupe by event id and process idempotently, so a duplicate delivery is a no-op. Where ordering matters, events are keyed (e.g. by aggregate id) so a partition preserves per-key order even though global order is not guaranteed.
End-to-end flow
A request arrives at the service — say, place an order. The handler opens a database transaction, writes the new order to the business table, and in the same transaction inserts an OrderPlaced row into the outbox table with a fresh event id and the serialized payload. It commits. At this instant the outcome is durable and atomic: the order exists and the intent to publish exists, together. The handler can return success to the caller immediately; publishing happens out of band.
The relay, running independently, notices the new outbox row — either by polling the table for unsent rows on its next tick, or by receiving the insert from a CDC stream tailing the transaction log. It reads the event and publishes it to the broker. Once the broker acknowledges receipt, the relay marks the outbox row as sent (polling) or relies on its CDC offset having advanced past that log position. The event is now on its way to consumers.
Downstream consumers receive the event and act on it — reserve inventory, send a confirmation, update a read model. Because delivery is at-least-once, a consumer may occasionally see the same event id twice (the relay crashed after publishing but before marking sent, and re-sent on restart). Each consumer therefore checks whether it has already processed that event id and, if so, ignores the duplicate; its side effects are idempotent so reprocessing changes nothing.
Failures at any step are safe. If the relay crashes mid-publish, the row is still unsent (or the CDC offset unadvanced) and will be retried. If the broker is down, the relay backs off and retries; the outbox rows wait durably in the database. If the service crashed right after commit but before the relay ran, the row is simply picked up whenever the relay next runs. At no point can a committed business change fail to eventually produce its event, and at no point does an uncommitted change produce a phantom one.
It is worth being precise about what this flow does and does not guarantee, because the difference drives the consumer contract. It guarantees atomicity between the data change and the recorded intent to publish, and it guarantees at-least-once eventual delivery of that intent to the broker. It does not guarantee exactly-once delivery, and it does not, by itself, guarantee any particular global ordering across different aggregates. Those two gaps are filled not by the outbox but by the consumers: every consumer treats the event id as an idempotency key, so a redelivered event is recognized and dropped, and any consumer that depends on order relies on the events being keyed to a partition that preserves per-aggregate sequence. Designing the flow with these boundaries explicit — atomic and at-least-once from the producer, idempotent and order-aware at the consumer — is what makes the pattern robust in practice rather than merely on paper.