Playbook

The Outbox/Inbox Pattern in Payment Systems (Outbox Inbox Pattern In Payment Systems)

If a database write and an event publish aren't in the same transaction, one can vanish or duplicate. Outbox publishes; inbox dedups on the consumer.

Distributed Payment Engine

Part 7 of 22

A series on distributed payment architecture — the gap between capture and complete.

Distributed payment engine architecture diagram

Two writes, not one transaction

When a service decides “the order is captured”, it usually wants to do two things: write a row to its database, and publish an event to a queue or broker. Because these two writes go to different systems, they can't be made atomic inside a single transaction. The gap between them is called the dual-write hole.

DbContext.SaveChanges()      → written to the database
Broker.Publish(event)        → if this fails, the event is lost
(or the order flips: the event is sent, but the DB transaction rolls back)

This part covers the outbox and inbox patterns that close that gap.

Concepts, defined where they first appear

📦 Dual-Write Problem
Trying to reflect the same business decision into two different systems (DB and broker) with two non-atomic writes.

📦 Outbox
An event table written in the same database transaction as the business decision, published later by a separate process.

📦 Lease
A temporary lock that stops multiple publisher processes from picking up the same outbox row at the same time.

📦 Inbox
A table on the consumer side where an incoming event is recorded before processing, filtering out duplicates.

📦 Effectively-once
The outcome of combining at-least-once delivery with an idempotent consumer — behaving, in practice, like “exactly once”.

Outbox solves the sender's problem (write + publish atomicity). Inbox solves the receiver's problem (redelivery). Together they build a reliable end-to-end event flow.

How the dual-write hole opens up

If a handler writes to the database and then publishes an event to the broker right after, the system can crash between those two separate steps. If the DB write succeeds but the broker call fails, the business decision becomes durable, but the event never gets published — downstream services never learn about it.

1. DB: Order.Status = Captured  ✓ (committed)
2. Broker.Publish(OrderCaptured)  ✗ (network error, process crash)

Result: the orders table says Captured, but no event reached any service

The reverse can also happen: the event gets published, but the DB transaction rolls back — downstream services then react to something that never actually happened.

Outbox: moving the write and the publish into the same transaction

The outbox pattern writes the event into an outbox table inside the same database transaction as the business decision, instead of publishing it directly to the broker. That row is now durable atomically with the business decision — either both commit, or neither does.

Single transaction:
  UPDATE orders SET status = 'Captured' WHERE id = 42;
  INSERT INTO outbox (event_type, payload, dispatched_at) VALUES ('OrderCaptured', ..., NULL);
COMMIT

A separate publisher process periodically scans the outbox for rows where dispatched_at IS NULL, publishes them to the broker, and fills in dispatched_at on success. If that publisher runs as multiple instances, a lease (a short-lived lock) keeps two instances from grabbing the same row at once.

Publisher A: leases row #7 (30s) → publishes to broker → dispatched_at = now()
Publisher B: row #7 is leased, skips it; looks for another row

This step guarantees the event gets published at least once — but if the publisher crashes, it can also get published twice. That's why the consumer side needs its own defense too.

Inbox: deduplication on the consumer side

Since outbox only guarantees at-least-once publishing, the consumer can receive the same event more than once. Instead of processing the event directly, the inbox pattern first writes it into an inbox table under a unique event ID; if that write already exists (unique constraint violation), the event isn't processed again.

Consumer receives event: event_id=evt_001
  INSERT INTO inbox (event_id, status) VALUES ('evt_001', 'received')
  → success: work gets queued to a job
  → unique constraint violation: already seen, silently skipped

Outbox + Inbox = effectively-once

Outbox guarantees the publish never gets lost (at-least-once). Inbox guarantees the same publish never gets processed twice on the consumer side (idempotent consumer). Combined, the system behaves, in practice, like “exactly once” — this is called effectively-once; a true exactly-once guarantee is nearly impossible in distributed systems, but this combination is a practically sufficient approximation.

Outbox (sender)  → at-least-once publish
Inbox (receiver) → idempotent consumption
─────────────────────────────────────────
Overall behavior → effectively-once

The mappings that get confused most often

❌ Writing to the DB and immediately publishing to the broker is safe
✓ These two steps aren't atomic; a dual-write hole exists between them

❌ Outbox alone prevents redelivery
✓ Outbox guarantees at-least-once; an inbox on the consumer side is needed against redelivery

❌ A lease is a permanent lock
✓ A lease is temporary and time-boxed; if the publisher crashes, it releases once it expires

❌ Effectively-once is the same thing as exactly-once
✓ Exactly-once isn't practical in distributed systems; effectively-once is what at-least-once plus idempotency produces

A checklist for auditing your outbox/inbox setup

  1. Is your business decision's DB write and event publish inside the same transaction, or two separate steps?
  2. If your outbox publisher runs as multiple instances, do you have a lease mechanism preventing two instances from grabbing the same row?
  3. If the publisher crashes, does the row become retryable once the lease expires, or does it stay “leased” forever?
  4. Does your inbox table on the consumer side have a unique constraint on event ID?
  5. Do you monitor the count of rows in the outbox where dispatched_at IS NULL (a signal of publish lag)?

If you can't confidently answer two of these five questions, your system is probably still exposed to the dual-write hole.

What to take away from this part

  1. A database write and an event publish go to different systems and aren't atomic; that gap is the dual-write hole.
  2. Outbox guarantees the sender's atomicity by writing the event into the same transaction as the business decision; a separate publisher process handles publishing.
  3. A lease prevents multiple publisher instances from processing the same outbox row at once; it's time-boxed, not permanent.
  4. Inbox prevents the same event from being processed twice on the consumer side; it turns outbox's at-least-once guarantee into an idempotent one.

An event published without an outbox can vanish the moment it's sent. An event received without an inbox can get processed twice, making that loss twice as expensive.

FAQ

Frequently asked questions

What is Dual-Write Problem?

Trying to reflect the same business decision into two different systems (DB and broker) with two non-atomic writes.

What is Outbox?

An event table written in the same database transaction as the business decision, published later by a separate process.

Is it true that "Writing to the DB and immediately publishing to the broker is safe"?

These two steps aren't atomic; a dual-write hole exists between them

What does this part lock in?

This part covers the outbox and inbox patterns that close that gap. A database write and an event publish go to different systems and aren't atomic; that gap is the dual-write hole. When a service decides “the order is captured”, it usually wants to do two things: write a row to its database, and publish an event to a queue or broker. Because these two writes go to different systems, they can't be made atomic inside a single transaction. The gap between them is called the dual-write hole.

Engineering Principles Learned

  • If a database write and an event publish aren't in the same transaction, the dual-write hole stays open; outbox closes it on the sender side.
  • Inbox is the mandatory counterpart that turns outbox's at-least-once guarantee into an idempotent one on the consumer side.
  • Effectively-once doesn't replace exactly-once; it's the practical outcome of at-least-once delivery combined with idempotent consumption.

Continue reading

Continue reading

Next in series

Next in series

Same series

Paylaş