Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha 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.13.1 Updated Edit this page Report a problem

On this page

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

KindPayload
entry.createdcontentType, workspaceId, title
entry.updatedsame, plus fields — the names that changed
entry.published, entry.unpublishedcontentType, workspaceId, title
entry.deleted, entry.restored, entry.purgedcontentType, 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 attempts and 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
AnswerWhen
200 + the row, attempts: 0Reset in place and drained before the response
404 Dead letter not foundUnknown id, or already delivered — deliberately alike
409Still climbing its backoff; it has not given up
403Caller lacks activity:manage (Admin only; no token scope carries it)
  • The event keeps its eventId, so subscribers still deduplicate.
  • lastError is 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.