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:

  1. Save the order
  2. Publish an OrderCreated event
await orderRepository.save(order);
await rabbitMQ.publish('order.created', order);

Looks perfect. Until production.

Failure #1 — the broker dies

StepResult
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

idevent_typepayloadstatuscreated_at
1OrderCreated{...}pending12:00:01
2PaymentSucceeded{...}pending12: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';
ColumnWhy it exists
aggregate_typeOrder, Payment, Invoice — filtering and debugging
aggregate_idLinks the event back to the business entity
event_typeOrderCreated, PaymentSucceeded, ...
payloadThe full event, usually JSON
retry_countNever retry forever — quarantine or DLQ
last_errorThe 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 version per aggregate
  • Let consumers buffer or reject out-of-order versions

12. Exactly-Once? Not Quite.

What you actually get:

PromiseReality
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 LOCKED for 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

Sign in to join the conversation.

Be the first to comment.