Durable Work in PostgresPart 11
Domain event, outbox row, or event-store record?
Use ownership, retention, and replay rules to tell domain events, outbox messages, queue jobs, and event-store records apart.
A design review labels the same OrderPaid row a domain event, an outbox message, an event-store
record, and a queue job. The SQL may still run, but the team no longer agrees on whether the row is
permanent business history, temporary delivery state, or disposable work.
Those names are not interchangeable. They tell operators what may be deleted, developers what may be replayed, and the business which record is authoritative.
Use one question to separate them:
If every derived table disappeared, which record would we trust to rebuild or continue the work?
Start with what the row means
Imagine checkout commits a payment and several things happen afterward. The rows might look similar, but their contracts differ:
| Record | What it means | Source of truth? | Retention | Safe replay |
|---|---|---|---|---|
| Domain event | The domain decided that an order was paid | Meaning comes from the domain model | Depends on how it is persisted | Only if handlers are designed for replay |
| Outbox row | The service still owes publication of OrderPaid | No, it carries publish intent | Until delivery evidence and audit policy allow cleanup | Retry delivery with the same event id |
| Inbox or queue row | This service accepted work it still owes | No, it tracks responsibility for work | Until completion and support policy allow cleanup | Retry only with an idempotent effect |
| Event-store record | OrderPaid is part of the order’s authoritative history | Yes | Usually permanent | Rebuild state and projections without repeating external effects |
| Projection row | A read model derived from authoritative history | No | Rebuildable | Rebuild from the source history |
The storage technology does not decide the pattern. A Postgres table can hold any of these records, and two tables with nearly identical columns may have opposite retention rules. The pattern comes from the record’s authority and recovery promise: whether it is business truth, temporary evidence that delivery is owed, a resumable unit of work, or a view that can be rebuilt from somewhere else.
One database can store records with different authority
Open in StudioA domain event names a business fact
OrderPaid should come from a domain decision. It says the order moved through a valid transition,
not that a worker happened to run.
const result = order.recordPayment(payment);
if (!result.ok) return result;
await db.transaction(async (tx) => {
await tx.orders.save(order);
await tx.outbox.append(result.event);
});
The domain event carries meaning. The outbox row carries the operational promise to publish that meaning. They may share an id and payload, but they have different reasons to exist.
An outbox row is not permanent business history
The transactional outbox closes one gap: the order update and the intent to publish commit together. The relay may attempt delivery several times. Once the event has reached the required transport and the retention window has passed, the row can usually be archived or deleted.
That is very different from an event-sourced record. Deleting an event-store row can make the current aggregate impossible to rebuild. Deleting an old outbox row should not change what the business believes happened.
Ask two review questions:
- If we delete this row after delivery, do we lose business truth?
- If we replay this row, are we rebuilding state or repeating a side effect?
If the team cannot answer, it has not chosen a pattern yet.
Event sourcing changes authority
In event sourcing, the stream of accepted events is the source of truth. Current order state is folded from that history. A table containing the latest order snapshot is a cache or projection, even if most reads use it.
That brings benefits when temporal history, audit, or alternate projections are core requirements. It also brings versioning, upcasting, replay, snapshot, and operational costs. A durable claim loop does not give you event sourcing. It only gives workers a safe way to claim rows.
CQRS is separate again. You can use different write and read models without event sourcing. The read model may be rebuilt from an event stream, updated from an outbox, or maintained in the same local transaction. State the source of truth instead of using “CQRS” as a substitute for that decision.
Sagas own progress, not global consistency
A saga or process manager records where a long-running business process is now. It reacts to facts, issues commands, waits, retries, and chooses compensation when a later step fails.
Its row is authoritative for workflow progress. It is not automatically authoritative for payment, inventory, or identity state owned by other contexts. Each context still protects its own rules.
That distinction prevents a common mistake: building one workflow table that slowly becomes a second copy of every domain involved.
Put the contract in the schema and runbook
For every durable table, record five facts:
- What does one row mean?
- Which model owns the truth?
- When may the row be deleted?
- What does replay do?
- Which id makes retry or rebuild safe?
Those answers should shape constraints, indexes, retention jobs, support tools, and tests. They also give a junior engineer a way to challenge vague architecture language during review.
Do not ask whether Postgres supports DDD, CQRS, sagas, or event sourcing. It can store all of them.
Ask what the row promises and which record the team will trust after a failure.
Source
This TypeScript file compares the record shapes used by the patterns in this article.