Outbox
patternThe pattern made it to production. We picked RabbitMQ over Kafka, which means we didn't get durable event storage and out-of-the-box replay — and yet there are areas where message delivery has to be guaranteed and processing idempotent. Outbox plugged exactly that hole.
We didn't write the implementation ourselves: we took the transactional outbox from MassTransit on top of RabbitMQ — the event is written in the same transaction as the domain changes, a background relay carries it to the broker, and an inbox on the consumer side provides deduplication for idempotency.
For some good reading on the topic — an article on Habr.
The Outbox pattern is a way to reliably publish events/messages from a service so that changing data in the DB and sending the message happen atomically (within a single transaction), without two-phase commits and without "lost" events.
Why you need it
The classic problem: in a request handler you
- write changes to the DB
- publish an event to a broker (Kafka/RabbitMQ/…)
If you crash between (1) and (2) — the data is already in the DB, but the event never left. If it's the other way around (the event went out but the transaction rolled back) — the receiver sees an event about something that "never happened". Outbox eliminates both scenarios.
The idea of the pattern
Instead of going "straight to the broker", the service writes the event to an outbox table in the same transaction as the domain changes:
Transaction:
UPDATE/INSERTon the domain tablesINSERT INTO outbox (...) VALUES (...)
A separate background process (publisher/relay) reads the outbox and sends to the broker.
After a successful send, it marks the record as sent (or deletes it).
Bottom line: if there's a change in the DB — there's guaranteed to be an event in the outbox, and sooner or later it will be published.
A typical Outbox data schema
The minimally useful fields:
Id(UUID/ULID)OccurredAt(when the event "happened")Type(event/contract name)Payload(JSON/Protobuf/base64)Headers/Metadata(traceId, tenantId, correlationId, schemaVersion)Status(Pending/Sent/Failed)RetryCount,NextAttemptAt,LastErrorPartitionKey/AggregateId(for per-entity ordering)
How to "pick up" messages from the outbox
Two popular approaches:
1) Polling
The publisher periodically runs a query:
- select N "pending" rows (often
ORDER BY occurred_at) - lock them (
FOR UPDATE SKIP LOCKEDin Postgres) — so multiple workers don't grab the same ones - send to the broker
- mark as
Sent
Pros: simpler, portable. Cons: latency, load on the DB.
2) CDC (Change Data Capture)
The service still writes to the outbox, but publishing is done by reading the WAL/binlog (Debezium and friends), then shipping to Kafka/… Pros: low latency and less polling load. Cons: trickier to operate.
Guarantees and important properties
Outbox gives you at-least-once delivery: an event may be sent more than once.
Which is why consumers must be idempotent:
- keep "processed message ids"
- use upserts keyed by event/aggregate
- dedupe on the consumer side
Exactly-once in distributed systems is almost always achieved only "by design" (idempotency + dedup), not through transport magic.
Message ordering
If you need ordering per aggregate (e.g. OrderId):
- store an
AggregateId/PartitionKey - send to Kafka partitioned by that key (then ordering is preserved within the key)
- with polling - select/publish sequentially per key (or serialize on the publisher)
Global ordering is usually unnecessary and expensive.
When Outbox is especially needed
- microservices + a message broker
- business events must "never get lost"
- you can't (or won't) drag in 2PC/Distributed Transactions
- you want predictable reliability and traceability