Toast
ContributorPatterns

Events

Events

Domain event patterns for Toast. Events decouple operations from their side effects (audit logging, webhooks, SSE streaming, automations).

See ADR-007 for architectural rationale.

When to Emit Events

Emit an event whenever a domain-meaningful mutation occurs. The rule: if another system might care about it, emit it.

Always emit for:

  • Resource creation, update, or deletion
  • State transitions (draft → published, login, logout)
  • Configuration changes (site settings)

Don't emit for:

  • Read operations (queries, listings)
  • Internal implementation details (cache invalidation, connection pooling)
  • Intermediate steps within a single operation

Event Naming

Events follow resource.verb naming in past tense. Events are facts about things that already happened:

content.created    ✓ (past tense, fact)
content.create     ✗ (imperative, command)
createContent      ✗ (function name, not event name)

Current taxonomy:

Event TypeResourceTrigger
content.createdcontentNew post/page created
content.updatedcontentPost/page modified
content.deletedcontentPost/page deleted
content.publishedcontentContent published
content.unpublishedcontentContent unpublished
user.createduserNew user added
user.updateduserUser profile changed
site.createdsiteNew site provisioned
site.settings.updatedsiteSite settings changed
auth.loginsessionUser signs in
auth.logoutsessionUser signs out

Adding New Events

  1. Add a type constant to EVENT_TYPES in domain-events.ts
  2. Define a data payload interface (e.g., MyResourceCreatedData)
  3. Define a concrete event interface extending DomainEvent<Type, Data>
  4. Add to the AnyDomainEvent union (the compiler enforces exhaustive handling)
  5. Create a factory function (e.g., createMyResourceCreatedEvent)
  6. Add an audit log mapping in audit-log.subscriber.ts

The exhaustive switch in mapEventToAuditEntry will produce a compile error if you add an event type to the union without handling it — this is intentional.

Event Structure

All events share a base shape:

interface DomainEvent<T, D> {
  id: string; // UUID, unique per event
  type: T; // e.g., 'content.created'
  siteId: string; // Multi-tenant isolation
  timestamp: string; // ISO 8601
  actor: {
    type: ActorType; // 'user' | 'api_key' | 'automation' | 'system'
    id: string | null;
  };
  data: D; // Event-specific payload
}

Key rules:

  • siteId is always present — events are tenant-scoped
  • actor identifies who/what triggered the event (human, API key, automation, system)
  • data should be self-contained — subscribers should be able to act without making follow-up API calls
  • timestamp is set at creation time, not at processing time

Emitting Events

Use factory functions to create typed events, then emit via the event bus:

import { createContentCreatedEvent, eventBus } from '../events/index.js';

// In your service function:
const event = createContentCreatedEvent({
  siteId,
  actor: { type: 'user', id: authorId },
  data: {
    id: row.id,
    title: row.title,
    body: row.body,
    slug: row.slug ?? null,
    status: row.status,
    contentType: row.contentType,
    authorId: row.authorId ?? null,
    featureImage: row.featureImage ?? null,
    excerpt: row.excerpt ?? null,
  },
});

eventBus.emit(event);

Important: emit() is fire-and-forget. It returns void, not Promise<void>. Never await it. The event bus defers handler execution via queueMicrotask so the emitting code path is never blocked by subscriber processing.

Before/After Pattern

For update and state-change events, capture the state before the mutation:

// 1. Read current state
const before = await contentRepository.findById(id, siteId);

// 2. Perform the mutation
const after = await contentRepository.update(id, siteId, updateData);

// 3. Emit with both snapshots
const event = createContentUpdatedEvent({
  siteId,
  actor: { type: 'user', id: actorId },
  data: {
    id,
    before: { title: before.title, slug: before.slug },
    after: { title: after.title, slug: after.slug },
  },
});
eventBus.emit(event);

For updated events, only include fields that changed in the before/after snapshots. Don't dump the entire entity — keep payloads focused on what actually changed.

For deleted events, capture the entity state before deletion so audit trails have the data.

Writing Subscribers

Subscribers are async functions that handle events. They register with the event bus at startup.

// events/subscribers/my-subscriber.ts
import { eventBus } from '../index.js';
import type { ContentCreatedEvent } from '../domain-events.js';
import type { EventHandler } from '../types.js';

const handleContentCreated: EventHandler<ContentCreatedEvent> = async (event) => {
  // event.data is fully typed as ContentCreatedData
  await doSomethingWith(event.data.title, event.siteId);
};

export function registerMySubscriber(): void {
  eventBus.subscribe('content.created', handleContentCreated);
}

Subscriber Rules

  1. Never throw — catch and log errors internally. A failing subscriber must never crash the emitter or block other subscribers.

  2. Don't depend on execution order — subscribers run concurrently via Promise.allSettled. If subscriber B needs subscriber A to finish first, that's a design smell.

  3. Be idempotent when possible — events may be delivered more than once in future queue-backed implementations.

  4. Use wildcards sparingly — subscribing to '*' means processing every event in the system. This is appropriate for cross-cutting concerns (audit logging) but not for feature-specific logic.

Wildcard Subscribers

Subscribe to '*' to receive all events. The audit log subscriber uses this pattern:

import { WILDCARD_EVENT_TYPE } from '../event-bus.js';

export function registerAuditLogSubscriber(): void {
  eventBus.subscribe(WILDCARD_EVENT_TYPE, auditLogHandler);
}

When handling wildcard events, use the isKnownDomainEvent type guard to narrow the type before processing:

import { isKnownDomainEvent } from '../domain-events.js';

const handler: EventHandler = async (event) => {
  if (!isKnownDomainEvent(event)) return;
  // event is now AnyDomainEvent — safe for exhaustive switch
};

Registering Subscribers

Subscribers are registered during application startup in events/subscribers/index.ts:

import { registerAuditLogSubscriber } from './audit-log.subscriber.js';

export function registerAllSubscribers(): void {
  registerAuditLogSubscriber();
  // Add new subscribers here
}

Testing Events

Testing Emission

Mock the event bus and verify the correct event was emitted:

const mockEventBusEmit = vi.fn();

vi.mock('../events/index.js', () => ({
  eventBus: { emit: mockEventBusEmit },
  createContentCreatedEvent: vi.fn((params) => ({
    id: 'mock-uuid',
    type: 'content.created',
    ...params,
  })),
}));

// In your test:
await createContent({ title: 'Test' }, siteId, authorId);

expect(mockEventBusEmit).toHaveBeenCalledWith(
  expect.objectContaining({
    type: 'content.created',
    data: expect.objectContaining({ title: 'Test' }),
  })
);

Testing Subscribers

Test subscribers directly by calling the handler function with a crafted event:

import { auditLogHandler } from './audit-log.subscriber.js';

it('writes audit entry for content.created', async () => {
  const event = createContentCreatedEvent({
    siteId: 'test-site',
    actor: { type: 'user', id: 'user-1' },
    data: { id: 'content-1', title: 'Test' /* ... */ },
  });

  await auditLogHandler(event);

  expect(mockAuditLog).toHaveBeenCalledWith(
    expect.objectContaining({
      action: 'content.created',
      resourceType: 'content',
    })
  );
});

Testing the Event Bus

The InMemoryEventBus supports unsubscribeAll() for test cleanup:

beforeEach(() => {
  eventBus.unsubscribeAll();
});

File Structure

apps/api/src/events/
├── types.ts                           # Base interfaces
├── domain-events.ts                   # Concrete types, payloads, factories
├── event-bus.ts                       # InMemoryEventBus
├── index.ts                           # Re-exports + singleton bus
├── event-bus.test.ts                  # Bus unit tests
├── domain-events.test.ts             # Type & factory tests
├── index.test.ts                      # Integration tests
└── subscribers/
    ├── index.ts                       # Registration entry point
    ├── audit-log.subscriber.ts        # Wildcard → audit log
    └── audit-log.subscriber.test.ts   # Subscriber tests

Future: Queue-Backed Bus

The current InMemoryEventBus is suitable for single-process deployments. For production reliability:

  • BullMQ with Redis for persistent, retryable event delivery
  • Dead letter queues for events that repeatedly fail
  • Separate read/write connections for high-throughput scenarios

The EventBus interface is designed for this swap — subscribers don't need to change when the transport layer upgrades.

On this page