Skip to content

Events and hooks

Modules never call each other: they communicate through domain events (asynchronous, with a transactional outbox) and hooks (synchronous extension points). Everything a module emits or consumes is declared in the manifest.

Domain events

The contract

interface DomainEvent<TPayload = unknown> {
  name: string;          // 'courses.course.published'
  version: number;
  data: TPayload;
  metadata: {
    tenantId: string;
    userId?: string;
    timestamp: string;
    traceId: string;
    idempotencyKey: string;
  };
}

interface EventBus {
  publish<T>(event: DomainEvent<T>): Promise<void>;
  subscribe<T>(eventName: string, handler: (e: DomainEvent<T>) => Promise<void> | void): () => void;
}

Naming convention: <module>.<entity>.<action> (courses.course.published, subscriptions.subscription.canceled, learning.course.completed).

Guaranteed delivery: transactional outbox

publish() does not fire the event directly: it persists it in the outbox_event table (deduplicating by (tenantId, idempotencyKey)) and a worker dispatches it over BullMQ. Without Redis, dispatch is immediate and in-process; a recovery worker requeues anything pending. The result: an event published inside a transaction is not lost even if the process crashes.

Choose the idempotencyKey carefully

Deduplication by idempotencyKey is permanent. If an entity can legitimately re-emit the same state (a subscription that is activated, cancelled and reactivated months later), use a per-occurrence key (${name}:${id}:${randomUUID()}), not a stable one — otherwise the second event is swallowed silently.

Subscribing

Subscriptions happen in onRegister (once per process, with no tenant) or in a host bridge:

// apps/api/src/modules/access-groups/access-groups-courses.bridge.ts (the pattern)
ctx.eventBus.subscribe('courses.course.published', async (event) => {
  // 1) Is my module active for event.metadata.tenantId? If not, return.
  // 2) React (ALWAYS filtering by tenantId).
});

Remember: disabling a module does not unsubscribe its handlers — check the state inside the handler.

Declaration in the manifest

Every published event goes in eventsEmitted; every subscribed event, in eventsConsumed. Emitting an undeclared event is an anti-pattern that review will reject.

Hooks

Hooks are synchronous, inside another module's flow: they let a module take part in someone else's operation without the owner knowing the guest exists.

Exposing a hook (the owner)

mod.courses declares in its manifest:

hooksExposed: [
  {
    name: 'courses.publish.validate',
    description: 'Permite añadir validaciones antes de publicar un curso.',
    async: true,
  },
],

And fires it from its service:

await this.ctx.hookRegistry.run('courses.publish.validate', {
  tenantId,
  input: { courseId, reasons: [] },
  metadata: {},
});
// if input.reasons ends up with entries, publishing is blocked

Registering with a hook (the guest)

mod.fundae adds its validation without mod.courses knowing it exists:

ctx.hookRegistry.register<{ courseId: string; reasons: string[] }>(
  'courses.publish.validate',
  async ({ input }) => {
    if (!hasObjective(input.courseId)) input.reasons.push('Falta objetivo Fundae');
  },
);

Handlers run sequentially; they communicate their result by mutating the shared input (the reasons[] pattern for vetoes).

Events and hooks vs. public services

When you need data from another module synchronously, the preferred route is not a hook but its public service, injected by the host (route A of ADR-016) — or, as a last resort, reading its tables directly with the dependency declared (route B, read-only). See Database.

Outgoing webhooks

Domain events flagged in the webhook catalog (learning.enrollment.created, community.post.created, billing.subscription.*…) are also forwarded to the outgoing webhooks administrators configure. If your event ought to be subscribable from outside, propose adding it to the catalog. See API → Conventions.