Toast
Contributor

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.ts

Important details:

  • EventBus contract lives in @toast/core
  • the default in-memory implementation also lives in @toast/core
  • the event bus instance is created by buildInfrastructure() in apps/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:

  • siteId is always present
  • actor identifies who or what triggered the event
  • data should 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:

  1. never crash the emitter path
  2. avoid depending on execution order
  3. prefer idempotent side effects where practical
  4. 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.


On this page