A change appends its events to a transactional outbox in the same transaction. The event commits if and only if the change does. After the commit, a dispatcher delivers it to every subscriber in the process.
Emit an event
// @acme/reviews-server — reviews.service.ts
import { Injectable } from '@nestjs/common';
import {
OutboxWriter,
UnitOfWork,
attachActor,
createDomainEvent,
type EventActor
} from '@orthacms/database';
import type { CreateReviewDto } from './create-review.dto';
import { reviews } from './schema';
@Injectable()
export class ReviewsService {
constructor(
private readonly uow: UnitOfWork,
private readonly outbox: OutboxWriter
) {}
create(workspaceId: string, dto: CreateReviewDto, actor: EventActor) {
return this.uow.run(async () => {
const [review] = await this.uow
.current()
.insert(reviews)
.values({ workspaceId, entryId: dto.entryId, authorId: actor.id, body: dto.body })
.returning();
const event = createDomainEvent({
kind: 'review.created',
aggregateType: 'review',
aggregateId: review.id,
payload: { workspaceId, entryId: review.entryId } // JSON-serialisable
});
// attachActor stamps payload.actor — what the activity log reads "who" from
await this.outbox.append(attachActor([event], actor));
return review;
});
}
}The envelope (DomainEvent):
{
eventId: string; // uuid — the idempotency key
kind: string; // 'review.created'
aggregateType: string; // 'review'
aggregateId: string;
occurredAt: Date;
payload: Record<string, unknown>;
}Appending outside a transaction throws
OutboxWriter.append must run inside UnitOfWork.run(...). Outside one, the
insert would auto-commit on its own and could outlive a rolled-back change — so
it is an error, not a silent inconsistency.
Subscribe to an event
Implement DomainEventSubscriber and register from onApplicationBootstrap.
This one reacts to content’s own entry.published:
// @acme/reviews-server — entry-published.subscriber.ts
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import {
InjectDatabase,
OutboxDispatcher,
type Database,
type DomainEvent,
type DomainEventSubscriber
} from '@orthacms/database';
import { and, eq, isNull } from 'drizzle-orm';
import { reviews } from './schema';
@Injectable()
export class EntryPublishedSubscriber implements DomainEventSubscriber, OnApplicationBootstrap {
readonly kinds = ['entry.published'] as const; // or '*' for every kind
constructor(
private readonly dispatcher: OutboxDispatcher,
@InjectDatabase() private readonly db: Database
) {}
onApplicationBootstrap(): void {
this.dispatcher.register(this);
}
/** Resolve open reviews once the entry ships. Safe to run twice. */
async handle(event: DomainEvent): Promise<void> {
await this.db
.update(reviews)
.set({ resolvedAt: event.occurredAt })
.where(and(eq(reviews.entryId, event.aggregateId), isNull(reviews.resolvedAt)));
}
}Add it to your module’s providers. Registration is a call, not a
multi-provider token, because Nest cannot merge one across dynamic modules.
Entry events content emits (aggregateType: 'content_entry', aggregateId =
entry id):
| Kind | Payload |
|---|---|
entry.created | contentType, workspaceId, title |
entry.updated | same, plus fields — the names that changed |
entry.published, entry.unpublished | contentType, workspaceId, title |
entry.deleted, entry.restored, entry.purged | contentType, workspaceId, title (deleted adds soft) |
Every audited payload also carries actor (id, email, type, label,
via) when the writer used attachActor.
How delivery works
- After the outermost transaction commits, the dispatcher drains undispatched
rows oldest first, 100 at a time, with
FOR UPDATE SKIP LOCKED. - A 5-second poll backs up a lost post-commit trigger.
- Concurrent drains collapse onto the one in flight plus one queued.
Delivery is at-least-once — subscribers must be idempotent
A redelivery is normal. The audit subscriber keys its row on the source
eventId with ON CONFLICT DO NOTHING. A subscriber that sends mail or bumps a
counter without an idempotency key will do it twice.
Retries back off, then stop
- A throwing subscriber bumps
attemptsand schedules the next try: 1 s, doubling, capped at 5 minutes. - After 15 attempts (
MAX_DELIVERY_ATTEMPTS, about half an hour) the row parks — it is no longer claimed.
The dead-letter list
Parked rows stay in outbox_events. Read them over HTTP (activity:read):
curl -b cookies.txt 'https://cms.example.com/api/activity/dead-letters?limit=50'{
"total": 1,
"items": [
{
"id": "5d0e…",
"kind": "review.created",
"aggregateType": "review",
"aggregateId": "c2d4…",
"occurredAt": "2026-09-30T10:12:44.120Z",
"attempts": 15,
"lastError": "connect ECONNREFUSED 10.0.0.7:443"
}
]
}Query params: limit (1–200, default 50) and since (ISO date-time). The
Activity page shows a notice whenever total is non-zero. Or query it directly:
SELECT * FROM outbox_events WHERE dispatched_at IS NULL AND attempts >= 15;Retrying a parked row
curl -b cookies.txt -X POST https://cms.example.com/api/activity/dead-letters/5d0e…/retry| Answer | When |
|---|---|
200 + the row, attempts: 0 | Reset in place and drained before the response |
404 Dead letter not found | Unknown id, or already delivered — deliberately alike |
409 | Still climbing its backoff; it has not given up |
403 | Caller lacks activity:manage (Admin only; no token scope carries it) |
- The event keeps its
eventId, so subscribers still deduplicate. lastErroris kept — the only record of why it parked.- The retry is itself audited as
outbox.event_retried.
One row at a time, after the cause is fixed
There is no bulk retry: a reset row goes to the head of an oldest-first queue.
Read lastError, fix the cause, then retry. Nothing alerts you outside the
admin — if you depend on delivery, poll the list from your monitoring.
Retention
Delivered rows are deleted once dispatched_at is older than
OUTBOX_RETENTION_DAYS (default 30; 0 keeps them forever).
OUTBOX_RETENTION_DAYS=30- The sweep runs at most hourly, 1,000 rows at a time.
- Pending and parked rows are never swept — a parked row is evidence of a gap.
Auditing is a subscriber
The activity log is written by the activity plugin’s subscriber mapping events to rows — not by the code that performs the action.
An event kind with no mapper is not an error: it is delivered to nobody and
never audited. If you want review.created in the activity log, add the mapper
too. Check for gaps by comparing the distinct kinds in outbox_events with the
audited set.
The outbox does not leave the process
Subscribers are plugins in the same server — no broker. What leaves the process is the webhooks plugin, itself a subscriber. Copy its shape if yours calls the network: queue rows inside the dispatcher’s transaction and let a worker send them (ADR-0016). A subscriber that makes the request itself holds a pool client for a stranger’s response time, and every failure counts against the event’s 15 attempts.