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:

RecordWhat it meansSource of truth?RetentionSafe replay
Domain eventThe domain decided that an order was paidMeaning comes from the domain modelDepends on how it is persistedOnly if handlers are designed for replay
Outbox rowThe service still owes publication of OrderPaidNo, it carries publish intentUntil delivery evidence and audit policy allow cleanupRetry delivery with the same event id
Inbox or queue rowThis service accepted work it still owesNo, it tracks responsibility for workUntil completion and support policy allow cleanupRetry only with an idempotent effect
Event-store recordOrderPaid is part of the order’s authoritative historyYesUsually permanentRebuild state and projections without repeating external effects
Projection rowA read model derived from authoritative historyNoRebuildableRebuild 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 Studio
Domain state and event history may be authoritative. Outbox, inbox, saga, and projection rows carry delivery, progress, or derived state.

A 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:

  1. If we delete this row after delivery, do we lose business truth?
  2. 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:

  1. What does one row mean?
  2. Which model owns the truth?
  3. When may the row be deleted?
  4. What does replay do?
  5. 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.