Skip to content

Events Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the events-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Two packages compose:

  • @modularityjs/eventsEventBus abstract contract (dispatch<T extends DomainEvent>(event) → EventDispatchResult), the @OnEvent(EventClass, options) method decorator, EventListenersPool for listener registration, and the DomainEvent interface ({ readonly occurredAt: Date }).
  • @modularityjs/events-{memory,redis} — drivers. Memory dispatches in-process, sequentially or in parallel per config. Redis pub/sub fans events across instances; needs RedisModule.

Define each event as a small class implementing DomainEvent. Pass the class to @OnEvent — the framework wires the dispatch by class identity (not by string). Listeners are regular @Injectable() services registered through EventListenersPool.

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import type { DomainEvent } from '@modularityjs/events';
import {
  EventBus,
  EventListenersPool,
  EventsModule,
  OnEvent,
} from '@modularityjs/events';
import { EventsMemoryModule } from '@modularityjs/events-memory';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';

class UserRegisteredEvent implements DomainEvent {
  readonly occurredAt = new Date();
  constructor(
    readonly userId: string,
    readonly email: string,
  ) {}
}

@Injectable()
class WelcomeEmailListener {
  @OnEvent(UserRegisteredEvent, { name: 'send-welcome-email' })
  async handle(event: UserRegisteredEvent): Promise<void> {
    console.log(`Welcome ${event.email}, id ${event.userId}`);
  }
}

@Injectable()
class RegistrationService {
  constructor(@Inject(EventBus) private readonly events: EventBus) {}

  async register(email: string): Promise<string> {
    const userId = crypto.randomUUID();
    await this.events.dispatch(new UserRegisteredEvent(userId, email));
    return userId;
  }
}

@Module({
  name: 'registration',
  imports: [EventsModule],
  providers: [RegistrationService, WelcomeEmailListener],
  pools: [
    {
      pool: EventListenersPool,
      key: 'welcome-email',
      useClass: WelcomeEmailListener,
    },
  ],
})
class AppModule {}

const app = await createApp({
  di: inversify,
  modules: [ModularityModule, EventsModule, EventsMemoryModule, AppModule],
});

@OnEvent options:

  • name (required) — stable identifier for the handler (used by once to track which instance ran it across deployments).
  • order — handler priority within an event; lower runs first.
  • once: true — process exactly once across all instances. Requires Redis (it uses Redis as the coordination store); meaningless on the memory driver.
  • local: true — only handlers in the dispatching instance run; events don't fan out to other nodes. Useful for in-process side effects (cache invalidation) that other nodes do for themselves.

once and local are mutually exclusive — combining them throws a ValidationException at decorator-time. local means "don't cross the bus", once means "exactly one instance handles it", which only makes sense when the event does cross the bus.

dispatch returns EventDispatchResult { ok, errors[] } — handler errors don't propagate. Check result.ok (or result.errors) when you need to know whether downstream handlers succeeded; otherwise the call is fire-and-forget.

Switching to Redis is EventsRedisModule + RedisModule.forRoot({ url }). RegistrationService and WelcomeEmailListener are unchanged.