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 Type | Resource | Trigger |
|---|---|---|
content.created | content | New post/page created |
content.updated | content | Post/page modified |
content.deleted | content | Post/page deleted |
content.published | content | Content published |
content.unpublished | content | Content unpublished |
user.created | user | New user added |
user.updated | user | User profile changed |
site.created | site | New site provisioned |
site.settings.updated | site | Site settings changed |
auth.login | session | User signs in |
auth.logout | session | User signs out |
Adding New Events
- Add a type constant to
EVENT_TYPESindomain-events.ts - Define a data payload interface (e.g.,
MyResourceCreatedData) - Define a concrete event interface extending
DomainEvent<Type, Data> - Add to the
AnyDomainEventunion (the compiler enforces exhaustive handling) - Create a factory function (e.g.,
createMyResourceCreatedEvent) - 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:
siteIdis always present — events are tenant-scopedactoridentifies who/what triggered the event (human, API key, automation, system)datashould be self-contained — subscribers should be able to act without making follow-up API callstimestampis 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
-
Never throw — catch and log errors internally. A failing subscriber must never crash the emitter or block other subscribers.
-
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. -
Be idempotent when possible — events may be delivered more than once in future queue-backed implementations.
-
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 testsFuture: 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.