A queue is not a pipe you dump JSON into. It is a durability and retry machine. The architecture is the guarantee you actually have (usually at-least-once), the outbox that keeps the database and the bus honest, and the consumer that can see the same message twice without corrupting state.
██████╗ ██╗ ██╗███████╗██╗ ██╗███████╗███████╗ ██╔═══██╗██║ ██║██╔════╝██║ ██║██╔════╝██╔════╝ ██║ ██║██║ ██║█████╗ ██║ ██║█████╗ ███████╗ ██║▄▄ ██║██║ ██║██╔══╝ ██║ ██║██╔══╝ ╚════██║ ╚██████╔╝╚██████╔╝███████╗╚██████╔╝███████╗███████║ ╚══▀▀═╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝╚══════╝
A checkout that calls email, ledger, inventory, and analytics inline will fail in the last hop and leave the first three committed. Queues separate "we accepted the fact" from "every downstream has reacted." The user-facing request writes a fact. Workers fan out.
That split is the whole subject. Everything else (partitions, competing consumers, dead letters) exists to keep that split correct under retry, crash, and backlog.
REQUEST PLANE ASYNC PLANE POST /orders queue / log / bus validate │ begin txn ├── email worker insert orders ├── ledger worker insert outbox ─────────────────────► ├── inventory worker commit └── search indexer 201 + order id The 201 does not mean email sent. It means the fact is durable and will be published.
Command: do this (SendInvoice). Event: this happened (InvoicePaid). Queues carry both. Do not name a command like an event. Consumers that think they are reacting to history will start issuing new side effects from a replay.
| Guarantee | What it means | What you still build |
|---|---|---|
| At-most-once | A crash can drop the message | Accept loss, or do not use this for money |
| At-least-once | A crash can duplicate the message | Idempotent consumers, dedupe store |
| Effectively-once | Broker + app cooperate so duplicates do not double-apply | Transactional produce/consume, idempotency keys |
Brokers that advertise exactly-once usually mean: produce and consume can be tied to an internal transaction so a message is not lost or double-appended inside that log. Your email provider is still at-least-once. Your HTTP webhook is still at-least-once. Design every consumer as if the message will arrive twice, possibly hours apart.
If you COMMIT the order and then PUBLISH, a crash between those lines loses the event. If you publish first and then commit, you emit a fact that rolled back. The outbox puts both writes in one ACID transaction. A separate process (poller, logical replication, trigger) publishes committed outbox rows and marks them sent.
BEGIN;
INSERT INTO orders (id, status, total_cents)
VALUES ($1, 'placed', $2);
INSERT INTO outbox (id, topic, payload, created_at)
VALUES ($3, 'order.placed', $4::jsonb, now());
COMMIT;
-- publisher (another process)
SELECT id, topic, payload FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
flowchart TD
A[HTTP handler] --> B[BEGIN]
B --> C[Write aggregate]
C --> D[Write outbox row]
D --> E[COMMIT]
E --> F[201 to client]
D -.-> G[Publisher]
G --> H{Broker ack?}
H -->|yes| I[Mark published_at]
H -->|no| G
Change data capture from the WAL can replace a handwritten outbox table. You still need a stable event schema, a way to ignore uninteresting tables, and a rule for what a tombstone means. CDC that dumps row images is not a public event contract.
Store event_id (or a hash of aggregate id + version) in a processed table with a unique constraint. Apply side effects in the same transaction as the insert when the side effect is local. For an external side effect (send email), record "we decided to send" before the provider call, and make the provider call itself idempotent with its own key.
async function consume(event: { id: string; type: string; data: unknown }) {
const inserted = await db.query(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
[event.id]
);
if (inserted.rowCount === 0) return; // duplicate delivery
await apply(event);
}
A DLQ nobody reads is disk usage. Give it a dashboard, a replay command, and a time-to-live. Replaying without fixing the handler puts the poison right back on the river.
CloudEvents (CNCF) standardizes id, source, type, specversion, time, and a data payload. Use it when events cross team or vendor boundaries. Inside one service you can be leaner. Do not invent a fourth envelope if you already have one.
{
"specversion": "1.0",
"id": "01J8Z3K4N2M",
"source": "https://orders.example.com/app",
"type": "com.example.order.placed.v1",
"time": "2026-08-26T12:00:00Z",
"datacontenttype": "application/json",
"data": {
"orderId": "ord_01J",
"totalCents": 4200,
"currency": "KES"
}
}
order.placed.v1 can add optional fields. v2 is for incompatible changes. Consumers subscribe to types they understand. A single "payload version inside data" field is how you get half-upgraded workers.
Watch consumer lag (offset delay or queue depth), not only error rate. A silent backlog is a delayed outage. Shed load on the producer (429 the HTTP plane) before the queue grows without a drain plan.
Global order is expensive. Order per partition key (customer id, order id) is usually enough. If two keys must be strictly ordered, they are one key. If they are not, do not share a partition just to feel safe.
| Need | Mechanism | Cost |
|---|---|---|
| One worker per aggregate | Keyed mailbox / partition key | Hot keys stall that key only |
| Fan-out to many teams | Log + consumer groups | Each group pays storage/read |
| Burst absorption | Queue depth + autoscale workers | Lag grows; set a max depth alarm |