Demystifying Apache Kafka: Why It's Not Just Another Message Queue ๐
What makes Kafka a distributed log rather than a queue, and when to reach for RabbitMQ or Redis instead.
How to stop losing (and duplicating) events when your service writes to a database and Kafka at the same time.
Kafka gives you a durable, replayable log โ but there's a gap it can't close for you: the moment your service updates its database and publishes an event. Get that seam wrong and you either lose events or fire phantom ones. The Outbox and Inbox patterns are how we close it. โ

Picture an order service. It needs to save the order to Postgres and publish order.created to Kafka. Two systems, two network calls, no shared transaction:
You can't wrap a database transaction and a Kafka publish in one atomic unit. Retries and try/catch just move the race around. The fix is to stop dual-writing entirely.
Don't publish to Kafka from your business logic. Instead, write the event into an outbox table in the same database transaction as your business change:
outbox. Both commit or neither does.The table is deliberately boring โ it lives in the same database as your business data, so it commits atomically with it:
-- inserted in the SAME transaction as the order
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL, -- 'order.created'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ -- NULL until the relay ships it
);The relay is just a loop over the unsent rows:
loop every N ms:
rows = SELECT * FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT 100
for row in rows:
kafka.publish(row.event_type, row.payload)
UPDATE outbox SET sent_at = now() WHERE id = row.idNow the source of truth is your database. If the relay crashes mid-flight, it just re-reads the unsent rows on restart โ the event is never lost, only delayed.
The relay guarantees at-least-once delivery, which means consumers will occasionally see the same event twice โ after a rebalance, a redelivery, or a retry. So the consumer must make reprocessing a no-op.
Keep an inbox table of message IDs you've already handled. Before processing an event, check it in the same transaction as your work:
That's idempotency: process each event exactly once in effect, even when Kafka delivers it more than once.
flowchart LR
subgraph Producer[Producer Service]
BL[Business logic]
DB[(DB + outbox<br/>one transaction)]
RELAY[Relay / CDC]
BL --> DB
DB --> RELAY
end
K[(Kafka<br/>append-only log)]
subgraph Consumer[Consumer Service]
CN[Consumer]
IN[(inbox: seen IDs<br/>one transaction)]
CN --> IN
end
RELAY -->|publish| K
K -->|at-least-once| CN| Pattern | Side | Guarantees | Costs you |
|---|---|---|---|
| Outbox | Producer | No lost events โ DB commit and event are atomic | An extra table + a relay to run |
| Inbox | Consumer | No duplicate side effects โ reprocessing is a no-op | An extra table + a dedup check per message |
Kafka handles durability and replay in the middle. The outbox anchors the event to your database on the way in, and the inbox absorbs redelivery on the way out. Together they turn "at-least-once" plumbing into effectively exactly-once business behavior โ without distributed transactions. ๐ฏ
What makes Kafka a distributed log rather than a queue, and when to reach for RabbitMQ or Redis instead.
When hand-wiring main.go stops scaling, and what Fx's graph resolution and lifecycle ordering buy you in return.
Practical patterns for retries, idempotency, and observability in Lambda-backed product APIs.