Features Apograph CMS on GitHub

Domain events and the outbox

How a change raises an event that cannot outlive it, and what happens to that event afterwards.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

A change appends its domain events to a transactional outbox using the same transaction as the change itself. The event commits if and only if the change does, and never without it.

await this.uow.run(async () => {
    await this.reviews.insert(review);
    await this.outbox.append([
        createDomainEvent({
            kind: 'review.created',
            aggregateType: 'review',
            aggregateId: review.id,
            payload: {}
        })
    ]);
});

An event envelope carries eventId, kind, aggregateType, aggregateId, occurredAt and payload. It is pure TypeScript with no framework in it.

Appending outside a transaction throws

OutboxWriter.append requires an ambient unit of work. On the base pool the insert would auto-commit on its own connection, and an event that outlives a rolled-back change is the exact thing the pattern exists to prevent — so it is an error rather than a silent inconsistency.

Subscribing

@Injectable()
export class ReviewNotifier implements DomainEventSubscriber {
    handle(event: DomainEvent): Promise<void> {}
}

Register with the dispatcher on application bootstrap. After the outermost transaction commits, the dispatcher drains undispatched rows oldest first, using FOR UPDATE SKIP LOCKED, and delivers each to its matching subscribers.

The drain is triggered post-commit and awaited, with a five-second poll as the backstop for a lost trigger.

Delivery is at-least-once

Subscribers must be idempotent

A redelivery is normal, not exceptional. The audit subscriber handles this by keying its row on the source event id and inserting ON CONFLICT DO NOTHING, so a second delivery writes nothing.

A subscriber that sends an email, calls an API or increments a counter without an idempotency key will do it twice.

Retries back off, then stop

A failing subscriber bumps the row’s attempt count and schedules the next attempt — one second, doubling, plateauing at five minutes. After 15 attempts, roughly half an hour, the row is no longer claimed at all.

Both halves are load-bearing. Without the ceiling, a batch of permanently failing rows sat at the head of the queue forever and nothing newer was ever delivered again. Without the backoff the ceiling would mean nothing either — drains are triggered by commits, so on a busy server fifteen attempts is milliseconds.

The dead-letter query

Parked rows stay in the table. The activity plugin exposes them as GET /api/activity/dead-letters (gated on activity:read, the same key as the log), and the Activity page shows a notice when the total is non-zero — because the subscriber a parked row was most often bound for is the audit mapper, so a non-zero count means the trail has a hole in it. The underlying query, if you would rather run it yourself:

SELECT * FROM outbox_events
WHERE dispatched_at IS NULL AND attempts >= 15;

Clearing attempts on a row replays it.

It reports, it does not repair

The route and the notice tell you something is parked; neither replays it. Replaying is a deliberate operator action against a fixed cause, not a button that re-runs whatever failed fifteen times — and there is no alert that reaches you outside the admin. If your plugin depends on delivery, poll the route or watch the query from your own monitoring.

One drain at a time

Concurrent drain calls collapse onto the drain in flight plus a single queued one, so a burst of commits does not open a connection per commit.

Auditing is a subscriber

This is worth understanding because it explains a class of silent gap.

The activity log is not written by the code that performs an action. The action raises an event; the activity plugin’s subscriber maps it to an audit row.

Which means an event kind with no mapper is not an error anywhere: the dispatcher finds no subscriber, marks the event delivered, and the action is simply never audited.

That has happened three times in this codebase. If you emit events you expect to see in the activity log, you must add the mapper too — and the check is one query comparing the distinct kinds in outbox_events against the audited set.

What this is not

The outbox itself does not leave the process

Subscribers are plugins in the same server. There is no message broker and no way to subscribe from outside the process.

What does leave the process is the webhooks plugin: a subscriber that turns the entry lifecycle events into signed HTTP deliveries. It is built on this mechanism, and the way it is built is the rule to copy if you write a subscriber that reaches the network yourself — the subscriber only queues rows inside the dispatcher’s transaction, and a worker POSTs them with nothing open (ADR-0016). A subscriber that makes the request itself holds a pool client for a stranger’s response time, and a throw counts against the outbox row’s fifteen attempts, parking an event the activity log was also waiting for.