Durable Work in PostgresPart 11
How this fits with DDD, Event Sourcing, CQRS, and sagas
How Postgres durable work fits with DDD, domain events, transactional outbox, event sourcing, CQRS projections, sagas, CDC, and workflow engines.
The Postgres queue is not an architecture by itself. It is infrastructure you can use inside several established enterprise patterns.
The clean way to fit it in is to ask one question: which model owns the truth? DDD aggregates own business invariants, event stores own replayable history, projections own read models, outboxes own publish intent, and workflow engines own durable progress.
Start with the source of truth
Most confusion comes from using pattern names as synonyms. They are not synonyms. They draw boundaries around different facts.
| Pattern | Owns this truth | Where Postgres durable work fits | Do not use it as |
|---|---|---|---|
| DDD aggregate | Current business invariants | Write inbox/outbox rows in the same transaction as aggregate changes | A background job runner |
| Domain event | Something meaningful happened in the domain | Persist event or outbox intent after aggregate decision | A raw table-change notification |
| Transactional outbox | Publish intent after a local commit | Relay rows with the same claim/lease loop | The system of record for history |
| Event sourcing | Replayable event history | Use workers for projections, subscriptions, and catch-up jobs | A queue table with completed rows deleted |
| CQRS projection | Derived read model | Claim and apply events idempotently with checkpoints | The write model |
| Saga / process manager | Progress across local transactions | Use workflow instance and step rows for durable progress | A distributed transaction |
| CDC | Observed physical row changes | Use when you cannot add outbox writes to the app | A domain event contract |
DDD decides what changed; durable work decides what happens later
In a DDD-style service, aggregates protect invariants inside a transaction. The durable-work table should not decide whether an order can be paid. It should record follow-up work after the aggregate decision is made.
BEGIN;
-- Aggregate decision: enforce business invariant
UPDATE orders
SET status = 'paid'
WHERE id = 9182
AND status = 'pending_payment';
-- Durable work created by that decision
INSERT INTO inbox (partition_key, payload, idempotency_key)
VALUES (
'order:9182',
'{"type":"send_receipt","order_id":9182}'::jsonb,
'send_receipt:9182'
);
COMMIT;
Domain events are meaning, not delivery
A domain event such as OrderPaid is a fact in the language of the business. The outbox is a delivery mechanism for that fact. Keeping those separate prevents two common mistakes: treating table changes as domain events, and treating the outbox as permanent event history.
| Concept | Example | Durability expectation |
|---|---|---|
| Domain event | OrderPaid | Business fact. Stable meaning. |
| Outbox row | Publish OrderPaid to transport | Delivery intent. May be archived after publish. |
| Broker message | Serialized event on a topic | Transport record. Retention depends on platform policy. |
| Projection checkpoint | Search indexed through event 381 | Consumer progress. Rebuildable from source history. |
Event sourcing changes the source of truth
With event sourcing, the event stream is the write-side source of truth. The Postgres claim loop still helps, but the durable row is usually a projection task, subscription checkpoint, or catch-up batch. It is not the event store unless you deliberately build an event store.
| Layer | Owns | Postgres durable-work role |
|---|---|---|
| Event store | Append-only history | Source stream for subscriptions |
| Command model | Valid decisions | Appends events after invariant checks |
| Projection worker | Read-model updates | Claims events or batches, applies idempotently, saves checkpoint |
| Read model | Query shape | Derived table, cache, index, or search document |
Sagas and workflows coordinate progress, not consistency
A saga or workflow coordinates a process across local transactions. It does not make those transactions atomic together. Each step commits locally, emits intent for the next step, and uses compensation when a later step cannot complete.
| Need | Use | Postgres implementation |
|---|---|---|
| Short choreography across services | Saga events | Outbox row per local commit, idempotent consumers |
| Service-local multi-step process | Durable workflow | Workflow instance + step rows |
| Long human approval process | Workflow engine | Dedicated platform, UI, timers, search, and history tooling |
| Undo after partial success | Compensation | Explicit compensating step or event |
CDC is integration infrastructure, not a domain model
Change Data Capture is useful when you cannot change the application write path. It observes row changes from the WAL and turns them into a stream. That is powerful, but it is not the same as a domain event. A CDC consumer sees orders.status changed; a domain event says OrderPaid.
| Use CDC when | Prefer outbox when |
|---|---|
| The app is legacy or owned by another team | You control the write path |
| Consumers need physical row changes | Consumers need business events |
| You can tolerate schema coupling | You want a stable event contract |
| You need broad replication into analytics/search | You need precise publish intent per domain action |
Choose the pattern by the question you are answering
| Question | Pattern that answers it | Durable-work table role |
|---|---|---|
| Can this command change the business state? | DDD aggregate / command model | None until after the decision |
| How do I publish after commit? | Transactional outbox | Relay queue |
| How do I update a read model? | CQRS projection | Projection work and checkpointing |
| How do I preserve replayable history? | Event sourcing | Subscription and projection workers |
| How do I coordinate multiple local commits? | Saga / workflow | Durable process state and step scheduling |
| How do I integrate a DB I cannot change? | CDC | Consumer work generated from WAL changes |
Source
Use the article for explanation, then use these files when you want the complete SQL and TypeScript in one place.