Skip to main content

Events

In-process event emitter for cross-cutting side effects. Decouples the service that owns the write from the services that care about it.


Purpose

When content is approved, three things happen:

  1. Thumbnail mips start generating.
  2. Tag usage counts tick up.
  3. Notifications (future) go out.

Putting all three inside ContentService.approve() bloats it and couples approval to unrelated domains. Emitting a single content.approved event and letting handlers subscribe keeps each concern in its own module.


Implementation

Mechanism

@nestjs/event-emitter (built-in, no broker required). Synchronous by default; mark handlers async for non-blocking work.

// src/events/events.module.ts
@Global()
@Module({
imports: [EventEmitterModule.forRoot({ wildcard: true, delimiter: '.' })],
exports: [EventEmitterModule],
})
export class EventsModule {}

Global scope so any module can emit without importing the events module.

Emitting

// src/content/content.service.ts
constructor(private readonly events: EventEmitter2) {}

async approve(slug: string, actorId: string): Promise<Content> {
// ... state transition ...
this.events.emit('content.approved', { contentId: saved._id.toString(), actorId });
return saved;
}

Emit is synchronous — listeners run before emit() returns. Heavy work in listeners must itself be non-blocking (spawn a job, fire-and-forget).

Listening

// src/thumbnails/thumbnails.listener.ts
@Injectable()
export class ThumbnailsListener {
constructor(private readonly thumbnails: ThumbnailsService, private readonly logger: Logger) {}

@OnEvent('content.approved', { async: true })
async handleApproval(payload: ContentApprovedEvent): Promise<void> {
try {
await this.thumbnails.generateFor(payload.contentId);
} catch (e) {
this.logger.error('thumbnail generation failed', { contentId: payload.contentId, err: e });
// Do not rethrow — handler errors must not propagate back to the emitter.
}
}
}

Every handler catches its own errors. A handler that throws kills the emitter chain in @nestjs/event-emitter default mode — we don't want the approval response to fail because a thumbnail job errored.


Event catalog

Stable names are part of the API. Payloads are TypeScript types shared across emitter and listener modules.

EventPayloadEmittersListeners
content.submitted{ contentId, submittedBy }ContentService.submit(notifications, moderation-queue badge — future)
content.approved{ contentId, actorId }ContentService.approvethumbnails, tag usage counts, broadcast
content.rejected{ contentId, actorId, reason? }ContentService.rejecttag usage counts (decrement if previously approved)
content.deactivated{ contentId, actorId }ContentService.softDeletetag usage counts
tag.renamed{ oldSlug, newSlug, contentCount }TagsService.renamecatalog cache invalidation, audit log
user.registered{ userId, email }AuthService.register(email verification — future)
auth.refresh.reused{ userId, familyId, ip, userAgent }AuthService.refresh (on detection)(alerting — future; logged at error level regardless)

Adding an event:

  1. Add a row here with a stable dotted name.
  2. Define the payload type in src/events/types.ts.
  3. Emitter imports the type.
  4. Listener imports the type.
  5. If the event is security-sensitive, add a log line at error level in the emitter so alerting can catch it without a listener.

Required variables and services

  • @nestjs/event-emitter dependency.
  • EventsModule imported in AppModule (global).
  • No env variables.

Gotchas

  • Synchronous by default. Without { async: true } on @OnEvent, every handler runs inline on the emitter's call stack. If any handler blocks, the API request blocks. Always mark handlers async.
  • No delivery guarantees. If the process crashes between emit and handler completion, the event is lost. Fine for thumbnails (regenerate on next approval or via a backfill script). NOT fine for money or email. When that matters, graduate to a durable queue (BullMQ + Redis).
  • No ordering guarantees across emitters. If two handlers update the same document, last-write-wins.
  • Listener errors are silent unless logged. Every listener logs on catch; there is no central dead-letter queue in v1.
  • Reflection cost. EventEmitter2 with wildcards uses trie-based lookups — fast, but a hundred handlers across a hundred events adds up. Not a concern at current scale.

Testing

  • Unit (emitter): mock EventEmitter2, call the service method, assert .emit was called with the right event name and payload.
  • Unit (listener): instantiate the listener with a mocked service, call the handler directly, assert the service was called.
  • Integration: boot the app, POST /content/approve, wait briefly, assert the side-effect occurred (e.g. a thumbnail job was enqueued / tag usage count incremented).

Don't test the emitter library itself — @nestjs/event-emitter has its own tests.