Durable Work in PostgresPart 6
Choose the right durable-work shape
How to choose inbox, outbox, projection, CDC, and saga patterns when implementing durable work in Postgres.
The same Postgres mechanics can power background jobs, outbox relays, projections, CDC consumers, webhooks, and short saga steps. The table shape changes because the source of truth changes.
Pick the established pattern by asking one question first: who owns the durable fact?
Start with the owner of the fact
Inbox, transactional outbox, projections, CDC, and sagas are not interchangeable names for the same table. They answer different ownership questions: did your API create work, did your domain transaction create a publish intent, did an event log create history, or did the WAL expose a row change?
The implementation still uses durable rows, claims, leases, retries, idempotency keys, and optional ordering guards. The difference is what the row represents.
| If the durable fact is… | Use | Producer | Replay? |
|---|---|---|---|
| A one-shot task inside your service | Inbox | API / cron INSERT | Rarely |
| A domain event that must publish after commit | Transactional outbox | Same transaction as domain write | From broker retention or archive |
| An event stream that rebuilds a read model | Projection | Event append | Always |
| A row change from an app you cannot modify | CDC | WAL replication | From replication slot or log retention |
| A multi-step process across services | Saga + outbox | Each step commits and enqueues next | Per-step idempotency |
Use an inbox for one-shot service-local work
Fire-and-forget jobs inside your service: send email, resize an image, call a partner API, or process a webhook payload. The producer and consumer share Postgres, or the producer writes rows that workers read.
| Postgres mechanism | Inbox usage |
|---|---|
partition_key | Stream to serialize, user:42, order:9182 |
| Producer | Separate API / webhook handler INSERT |
| Handler | Arbitrary side effect (SMTP, S3, HTTP) |
| Replay | Usually none: row is one-shot work |
Use an outbox when publish intent must commit with the domain write
You cannot publish to external message infrastructure inside the same ACID transaction as your UPDATE orders. The outbox makes intent durable in the same commit. A relay worker (same Competing Consumers loop) publishes at-least-once to your chosen transport. Downstream must dedupe, whether they consume from a broker or write an inbox row.
| Postgres mechanism | Outbox usage |
|---|---|
| Queue table | outbox: same columns as inbox |
partition_key | order:9182: per-aggregate publish order |
idempotency_key | event_id: broker message key + consumer dedupe |
| Producer | Your domain transaction, not a separate service |
| Handler | Publish to transport |
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 9182;
INSERT INTO outbox (partition_key, event_type, payload, idempotency_key)
VALUES (
'order:9182',
'OrderPaid',
'{"order_id":9182,"paid_at":"2026-07-01T12:00:00Z"}'::jsonb,
'evt-order-9182-paid-v1'
);
COMMIT;
Use a projection when replay is part of the contract
The event store is the source of truth. Search indexes and caches are derived views. Workers catch up stream by stream with checkpoints: poll the tail or consume via outbox/inbox push.
| Postgres mechanism | Projection usage |
|---|---|
partition_key | stream_id / aggregate id |
| Ordering guard | Mandatory: seq N before N+1 per stream |
| Idempotency | Keyed by event_id or (stream_id, seq) |
| Replay | First-class: rebuild read model from seq 0 |
| Leases | On catch-up batch: prevent double-apply on crash |
const checkpoint = await loadCheckpoint(streamId);
const events = await eventStore.readFrom(streamId, checkpoint + 1);
for (const evt of events) {
await applyIdempotent(evt, { key: evt.id });
await saveCheckpoint(streamId, evt.sequence);
}
Use CDC when the write path cannot add an outbox
CDC fits legacy tables or polyglot stacks where you cannot add outbox INSERTs to every write path. It reads the WAL and turns row changes into a stream. Prefer outbox for greenfield systems; use CDC when you need to integrate existing databases.
| Postgres mechanism | CDC usage |
|---|---|
partition_key | Primary key of source row |
| Producer | Replication infrastructure. Not your app |
| Event shape | Row before/after images. Not domain events |
| Idempotency | Offset + PK + op type |
CDC vs outbox
| Transactional outbox | CDC | |
|---|---|---|
| Event meaning | Domain: OrderPaid | Physical: orders.status = 'paid' |
| App changes required | Yes: INSERT outbox in TX | No: tap existing writes |
| Schema coupling | Loose: payload is your contract | Tight: consumers break on column renames |
| Deletes / tombstones | Explicit event | Must handle DELETE records |
Use saga steps for short durable cross-service progress
Multi-step processes across services need a durable place to remember progress. Each step is a row, and each successful step enqueues the next action or a compensation. Use partition_key = saga:{instanceId} so confirm and compensate steps stay ordered. This is suitable for short idempotent choreographies; it is not a full workflow engine.
| Flavor | Entry point | Handler | partition_key |
|---|---|---|---|
| Saga step | Prior step completes → enqueue next | Call service + emit or compensate | saga:{instanceId} |
| Outbound webhook | Outbox or inbox row | HTTP POST with retries + signing | tenant:99 or entity id |
| Inbound webhook | Partner POST → INSERT inbox | Process async | Partner’s entity id |
async function deliverWebhook(row) {
const sig = sign(row.payload, WEBHOOK_SECRET);
const res = await fetch(row.payload.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": row.idempotency_key,
"X-Signature": sig,
},
body: JSON.stringify(row.payload.body),
});
if (!res.ok) throw new RetryableError(res.status);
}
Use a library unless the shape needs custom control
For ordinary background jobs, start with pg-boss, Graphile Worker, or River if one fits your stack. Keep the custom Postgres implementation when the shape needs guarantees the library does not expose cleanly.
- Outbox rows in the same transaction as domain writes with identical column semantics
- Custom
lease_generationfencing passed to downstream stores - One worker loop shared across inbox, outbox relay, and projection tables
- Full control over ordering guard and bucket claim queries
Source
Use the article for explanation, then use these files when you want the complete SQL and TypeScript in one place.