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…UseProducerReplay?
A one-shot task inside your serviceInboxAPI / cron INSERTRarely
A domain event that must publish after commitTransactional outboxSame transaction as domain writeFrom broker retention or archive
An event stream that rebuilds a read modelProjectionEvent appendAlways
A row change from an app you cannot modifyCDCWAL replicationFrom replication slot or log retention
A multi-step process across servicesSaga + outboxEach step commits and enqueues nextPer-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 mechanismInbox usage
partition_keyStream to serialize, user:42, order:9182
ProducerSeparate API / webhook handler INSERT
HandlerArbitrary side effect (SMTP, S3, HTTP)
ReplayUsually 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.

Outbox and inbox share the claim loop
Postgres mechanismOutbox usage
Queue tableoutbox: same columns as inbox
partition_keyorder:9182: per-aggregate publish order
idempotency_keyevent_id: broker message key + consumer dedupe
ProducerYour domain transaction, not a separate service
HandlerPublish 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.

Event stream and projection checkpoint
Postgres mechanismProjection usage
partition_keystream_id / aggregate id
Ordering guardMandatory: seq N before N+1 per stream
IdempotencyKeyed by event_id or (stream_id, seq)
ReplayFirst-class: rebuild read model from seq 0
LeasesOn 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 mechanismCDC usage
partition_keyPrimary key of source row
ProducerReplication infrastructure. Not your app
Event shapeRow before/after images. Not domain events
IdempotencyOffset + PK + op type

CDC vs outbox

Transactional outboxCDC
Event meaningDomain: OrderPaidPhysical: orders.status = 'paid'
App changes requiredYes: INSERT outbox in TXNo: tap existing writes
Schema couplingLoose: payload is your contractTight: consumers break on column renames
Deletes / tombstonesExplicit eventMust 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.

FlavorEntry pointHandlerpartition_key
Saga stepPrior step completes → enqueue nextCall service + emit or compensatesaga:{instanceId}
Outbound webhookOutbox or inbox rowHTTP POST with retries + signingtenant:99 or entity id
Inbound webhookPartner POST → INSERT inboxProcess asyncPartner’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_generation fencing passed to downstream stores
  • One worker loop shared across inbox, outbox relay, and projection tables
  • Full control over ordering guard and bucket claim queries