Events
Domain events, subscriber patterns, and audit-log integration.
Domain events let Toast separate the thing that happened from the side effects that follow.
Examples:
- content created
- content published
- site settings updated
- user updated
Audit logging is the main shipped subscriber today, and the same event system is designed to support future delivery mechanisms like webhooks.
See ADR-007 for the architectural decision.
Current shape
The event system is split across two places:
packages/core/src/
├── events.ts # EventBus / DomainEvent contracts
├── event-bus.ts # InMemoryEventBus + createEventBus()
apps/api/src/events/
├── domain-events.ts # concrete Toast event types + factory helpers
├── index.ts # re-exports for app code
├── types.ts # app-level event typing helpers
└── subscribers/
├── audit-log.subscriber.ts
└── index.tsImportant details:
EventBuscontract lives in@toast/core- the default in-memory implementation also lives in
@toast/core - the event bus instance is created by
buildInfrastructure()inapps/api/src/container.ts - there is no global singleton bus in the active runtime
When to emit an event
Emit an event for a domain-meaningful mutation that another part of the system might care about.
Good candidates:
- resource creation, update, deletion
- publish/unpublish transitions
- settings changes
- auth session events
Do not emit events for:
- reads and listings
- low-level implementation details
- intermediate steps inside a single operation
Event shape
All domain events share a base structure:
interface DomainEvent<TType, TData> {
id: string;
type: TType;
siteId: string;
timestamp: string;
actor: {
type: 'user' | 'api_key' | 'automation' | 'system';
id: string | null;
};
data: TData;
}Rules:
siteIdis always presentactoridentifies who or what triggered the eventdatashould be useful to subscribers without forcing immediate follow-up queries
Emitting events
Services emit through the injected EventBus.
export function createContentService(deps: {
contentRepository: ContentRepository;
siteRepository: SiteRepository;
eventBus: EventBus;
}) {
return {
async createContent(input: CreateContentInput, siteId: string, authorId: string) {
const row = await deps.contentRepository.create({
...input,
siteId,
authorId,
});
deps.eventBus.emit(
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,
},
})
);
return row;
},
};
}emit() is fire-and-forget. Services should not wait on subscriber work.
Subscribers
Subscribers are created with explicit dependencies and registered during startup.
const auditSubscriber = createAuditLogSubscriber({
auditLogService: stacks.auditLogService,
eventBus: infra.eventBus,
});
auditSubscriber.register();That wiring currently happens in apps/api/src/index.ts.
Subscriber rules:
- never crash the emitter path
- avoid depending on execution order
- prefer idempotent side effects where practical
- use wildcard subscriptions only for cross-cutting concerns
Wildcard subscriptions use WILDCARD_EVENT_TYPE from @toast/core.
Testing
For service tests, inject a plain mock bus:
const eventBus = {
emit: vi.fn(),
subscribe: vi.fn(),
unsubscribeAll: vi.fn(),
};Then assert on eventBus.emit(...).
For subscriber tests, call the subscriber handler or registration flow directly with crafted events.