Inbox + Outbox Combined
How They Work Together#
Outbox and Inbox solve two different sides of the same problem:
| Pattern | Side | Problem it Solves |
|---|---|---|
| Outbox | Producer | Guarantee event is published to Kafka after DB write |
| Inbox | Consumer | Guarantee event is not processed twice |
Together they give you exactly-once processing end-to-end — even though Kafka itself only guarantees at-least-once.
The Pattern#
When a consumer needs to both process an event AND publish a new event downstream, it uses inbox + outbox together in one transaction:
BEGIN TRANSACTION
-- Inbox: dedup check (consumer side)
INSERT INTO inbox (event_id) VALUES ('evt-456')
ON CONFLICT (event_id) DO NOTHING
-- Business logic: actual DB work
UPDATE orders SET status = 'shipped' WHERE order_id = 123
-- Outbox: publish next event (producer side)
INSERT INTO outbox (event_type, payload)
VALUES ('SendShippingEmail', '{"order_id": 123, "email": "user@gmail.com"}')
COMMIT
Three things in one atomic transaction: 1. Mark incoming event as processed (inbox) 2. Do the business work 3. Queue the next event for publishing (outbox)
Why This Works#
If the service crashes at any point: - Before commit → all three roll back → event retried → safe - After commit → inbox has the event_id → duplicate delivery skipped
Debezium picks up the outbox row and publishes SendShippingEmail to Kafka. The email service then processes it with its own inbox check.
Terminal Actions — The Exception#
Not everything can go inside a transaction. External calls (sending emails, calling payment APIs, pushing push notifications) cannot be wrapped in a DB transaction.
These are called terminal actions — the last step in a chain, with no further DB consistency requirements.
The Tradeoff: Lost vs Duplicate#
Option A: Do action first, then mark processed
1. Send email (external call)
2. BEGIN TRANSACTION
INSERT INTO inbox (event_id) ON CONFLICT DO NOTHING
COMMIT
Option B: Mark processed first, then do action
1. BEGIN TRANSACTION
INSERT INTO inbox (event_id) ON CONFLICT DO NOTHING
COMMIT
2. Send email (external call)
Which to Choose?#
Always prefer duplicate over lost for user-facing actions.
Duplicate email = user slightly annoyed. Lost email = user has no idea what happened, support ticket, churn.
When to Use Inbox + Outbox Together#
Use this combined pattern when: - You are consuming from Kafka AND producing to Kafka - You need atomic consistency between the incoming event, your DB state, and the outgoing event - Example: Order Service consuming OrderShipped and producing SendShippingEmail
Use only Inbox when: - You are consuming from Kafka but NOT producing further events - Example: Read Model Updater consuming OrderCreated and updating read model
Use only Outbox when: - You are producing an event from a DB write (not consuming Kafka) - Example: App Service writing a new order and publishing OrderCreated
Key Insight#
Inbox + Outbox is the contract that makes event-driven systems reliable. Without it, you have races, duplicates, and silent data loss. With it, every step in the chain is atomic and recoverable. The only exception is terminal external calls — and there, you choose the failure mode deliberately: duplicate over lost.