Reliable Event-Driven Systems: The Transactional Outbox Pattern with RabbitMQ
Eliminating dual-write anomalies between relational databases and message brokers with guaranteed at-least-once delivery.
1. The Dual-Write Hazard in Microservices
The moment a service needs to persist state in PostgreSQL and publish an event to RabbitMQ about that same state change, you have two independent systems that need to agree on an outcome neither of them can guarantee alone. A naive implementation commits the database transaction, then calls the broker client to publish. Those are two separate network-visible operations, and there is no atomic primitive spanning both, so any ordering of them leaves a window where one succeeds and the other fails.
If the process crashes, the pod gets OOM-killed, or the network to RabbitMQ times out after the COMMIT but before the publish confirms, you end up with a row in the database that no consumer will ever hear about. Order gets created, payment gets charged, but the fulfillment service never receives the OrderCreated event because the publish never happened. This is not a rare edge case: under load, connection resets and broker failovers happen routinely, and every one of them is a potential silent data loss event.
Flipping the order does not help. Publish first, then commit, and you risk the opposite failure: a consumer reacts to an event describing a row that the subsequent COMMIT never actually persisted, because the transaction got rolled back by a constraint violation or a deadlock. Now a downstream service is acting on a fact that is false in your source of truth. Neither ordering is safe, because you are trying to get atomicity across a relational database and a message broker, and no combination of retries at the application layer closes that gap by itself.
2. The Transactional Outbox Solution
The transactional outbox pattern collapses the two writes into one. Instead of publishing to RabbitMQ inside the request path, you write the event as a row in an `outbox_events` table, in the same PostgreSQL transaction that writes your business state. Because both writes go through the same ACID transaction, they succeed or fail together — there is no window where one commits without the other. Atomicity is achieved by never actually needing cross-system atomicity in the first place.
A separate, asynchronous dispatcher process then reads unpublished rows from the outbox table and relays them to RabbitMQ. This decouples the reliability of your business transaction from the availability of the broker: if RabbitMQ is down for five minutes, your API keeps accepting orders and writing outbox rows, and the backlog simply drains once connectivity is restored. The broker becomes a delivery mechanism, not a dependency that the request path has to trust synchronously.
The outbox table itself should carry a minimal, self-describing schema: an id, an aggregate/entity type and id for partitioning and debugging, the event type, a JSONB payload, a created_at timestamp, and a nullable dispatched_at that the dispatcher sets once RabbitMQ has confirmed the publish. Keeping the payload as pre-serialized JSON at write time — rather than re-deriving it from live application state later — also protects you from schema drift between the moment the event happened and the moment it gets sent.

