The Transactional Outbox Pattern: The Missing Piece Between Your Database and Message Broker
Why dual writes silently corrupt distributed systems — and how to publish events you can actually trust.

"The order was created successfully, but the customer never received a confirmation email."
Or worse:
"The payment was processed, but inventory was never updated."
These are not ordinary bugs. They are distributed consistency problems, and they happen in production every single day.
If you build microservices with NestJS, Kafka, RabbitMQ, Pulsar, NATS, or SQS, you have already met this problem — you may just not have named it yet. The Transactional Outbox Pattern exists to solve it.
1. The Fundamental Problem
Imagine an e-commerce platform. A customer buys a laptop. Your Order Service does two things:
- Save the order
- Publish an
OrderCreatedevent
await orderRepository.save(order);
await rabbitMQ.publish('order.created', order);
Looks perfect. Until production.
Failure #1 — the broker dies
| Step | Result |
|---|---|
| Save order | ✅ committed |
| Publish event | ❌ broker offline |
The database says the order exists. RabbitMQ never heard of it. Inventory never reserves stock. Notifications never fire. Analytics never records revenue.
Failure #2 — the database dies
The event publishes, then the transaction rolls back. Now every downstream service believes in an order that does not exist.
Congratulations: you shipped a ghost order.
2. The Dual Write Problem
This is the classic dual write: two independent systems, two independent failure modes, and no shared transaction.
Service ──▶ Database (may succeed)
└─▶ Message Broker (may fail)
Locally everything works, because locally nothing ever fails.
Why not one transaction?
Traditional transactions work because everything lives in one engine:
BEGIN;
INSERT ...;
UPDATE ...;
COMMIT;
RabbitMQ is not inside PostgreSQL. Kafka is not inside MySQL. You cannot COMMIT a broker publish.
3. Why Two-Phase Commit Isn't the Answer
2PC asks every participant "are you ready?", then commits. In theory it is correct. In practice it is:
- Slow (multiple network round trips holding locks)
- Fragile (coordinator failure blocks participants)
- Poorly supported by modern brokers
- An operational burden
Cloud-native systems overwhelmingly choose eventual consistency instead.
4. The Transactional Outbox Pattern
Stop writing to two systems. Write to one database, two tables, inside one transaction.
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (...) VALUES (...);
COMMIT;
Both succeed, or both fail. Never half.
The outbox table is a queue inside your database
| id | event_type | payload | status | created_at |
|---|---|---|---|---|
| 1 | OrderCreated | {...} | pending | 12:00:01 |
| 2 | PaymentSucceeded | {...} | pending | 12:00:04 |
Notice: the broker is not involved yet. That is the entire trick.
5. The Relay Process
A background worker reads pending rows, publishes them, and marks them published.
Client
│
▼
Order Service
│
▼
PostgreSQL
├── orders
└── outbox
│
▼
Outbox Relay
│
▼
RabbitMQ / Kafka
│
├─▶ Inventory Service
├─▶ Notification Service
└─▶ Analytics Service
The business transaction and the publication are now decoupled in time but coupled in truth.
6. Failure Scenarios
Broker crashes. Rows stay pending. When the broker returns, the relay drains the backlog. Nothing is lost.
Relay crashes after publishing, before updating status. On restart it republishes. A duplicate may reach consumers.
That is the deal the pattern makes with you:
The outbox guarantees you never lose an event. It does not guarantee you never deliver one twice.
7. Idempotency Is Mandatory
Every consumer must be able to see the same event twice and do the right thing.
async handleOrderCreated(event: OrderCreated) {
const seen = await this.processed.exists(event.id);
if (seen) return; // safe no-op
await this.reserveStock(event);
await this.processed.record(event.id);
}
Deduplicate on a stable event id, not on payload contents.
8. Database Design
CREATE TABLE outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
retry_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
last_error text
);
CREATE INDEX outbox_pending_idx
ON outbox (created_at)
WHERE status = 'pending';
| Column | Why it exists |
|---|---|
aggregate_type | Order, Payment, Invoice — filtering and debugging |
aggregate_id | Links the event back to the business entity |
event_type | OrderCreated, PaymentSucceeded, ... |
payload | The full event, usually JSON |
retry_count | Never retry forever — quarantine or DLQ |
last_error | The first question ops will ask |
9. Polling vs Change Data Capture
Polling publisher
SELECT * FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
FOR UPDATE SKIP LOCKED is what lets you run multiple relay instances safely.
- ✅ Simple, reliable, database-only
- ❌ Slight latency, constant query load
CDC (Debezium)
PostgreSQL WAL ─▶ Debezium ─▶ Kafka ─▶ Consumers
- ✅ Near real-time, no polling load
- ❌ More infrastructure to run and understand
Start with polling. Move to CDC when polling becomes the bottleneck — not before.
10. NestJS Architecture
src/
├── orders/
├── payments/
├── outbox/
│ ├── outbox.entity.ts
│ ├── outbox.service.ts
│ ├── relay.service.ts
│ ├── relay.scheduler.ts
│ └── publisher.service.ts
├── messaging/
└── shared/
The rule that matters: OrderService never touches the broker.
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async createOrder(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
const order = await manager.save(Order, Order.from(dto));
await manager.save(OutboxEvent, {
aggregateType: 'Order',
aggregateId: order.id,
eventType: 'OrderCreated',
payload: { orderId: order.id, amount: order.amount },
});
return order; // one commit, one truth
});
}
}
And the relay:
@Injectable()
export class RelayService {
@Cron('*/1 * * * * *')
async drain() {
const batch = await this.outbox.claimPending(100);
for (const event of batch) {
try {
await this.publisher.publish(event);
await this.outbox.markPublished(event.id);
} catch (err) {
await this.outbox.markFailed(event.id, err);
}
}
}
}
11. Ordering Guarantees
OrderCreated → OrderPaid → OrderShipped is meaningless if it arrives as OrderShipped → OrderCreated.
- Partition by
aggregate_id(Kafka gives per-partition ordering) - Publish in creation order, one aggregate at a time
- Include a monotonic
versionper aggregate - Let consumers buffer or reject out-of-order versions
12. Exactly-Once? Not Quite.
What you actually get:
| Promise | Reality |
|---|---|
| No lost events | ✅ Yes |
| Durable, replayable history | ✅ Yes |
| Reliable retries | ✅ Yes |
| No duplicates | ❌ No — dedupe in the consumer |
Outbox + idempotent consumers + a reliable broker is effectively-once, and that is what production systems actually run on.
13. Monitoring
An outbox table you don't watch is an outage you haven't noticed yet. Track:
- Pending event count
- Age of the oldest pending event (the single best alarm)
- Publish latency p50/p99
- Retry and failure counts
- Relay throughput
14. Performance
- Partial index on
status = 'pending' - Batch publishes; batch status updates
FOR UPDATE SKIP LOCKEDfor concurrent relays- Archive or purge published rows on a retention schedule
- Consider CDC when polling load hurts
Treat the outbox as production infrastructure, because it is on your critical path.
15. Common Mistakes
- Publishing to the broker inside the business transaction
- Writing the outbox row in a different transaction
- Deleting failed events instead of quarantining them
- Assuming consumers never see duplicates
- Polling aggressively enough to hurt the database
- Never cleaning up published rows
- No alerting on backlog age
16. When to Use It — and When Not To
Use it when:
- Multiple services communicate through events
- Losing an event causes real business inconsistency
- You publish to Kafka, RabbitMQ, Pulsar, or SQS
- Business writes and event publication must succeed together
Skip it when:
- You are a small monolith with no async messaging
- Event loss is genuinely acceptable
- Synchronous calls are sufficient
- The operational overhead outweighs the benefit
Every pattern costs something. Pay only where the business value is real.
The Bigger Picture
The Transactional Outbox Pattern is not about RabbitMQ or Kafka. It is not even about messaging. It is about one uncomfortable truth:
Independent systems fail independently.
Instead of pretending failures won't happen, the outbox records your intent to publish with the same durability as the business fact itself — and then keeps trying.
Final Thoughts
The difference between a demo and a production distributed system is rarely the framework, the database, or the language. It is how the system behaves when things go wrong.
The outbox quietly prevents the most damaging class of failures: lost orders, missing payments, forgotten notifications, inconsistent inventory, broken workflows.
It doesn't make failures disappear. It makes them recoverable.
Reliable software isn't software that never fails. It's software that fails predictably, recovers gracefully, and never loses what matters most.
Comments · 0
Be the first to comment.