Skip to content

Events

Contract

@modularityjs/events defines the abstract EventBus and @OnEvent decorator:

typescript
abstract class EventBus {
  abstract dispatch<T extends DomainEvent>(
    event: T,
  ): Promise<EventDispatchResult>;
  abstract getHandlers<T extends DomainEvent>(
    event: EventClass<T>,
  ): readonly ResolvedEventHandler[];
  abstract getAllHandlers(): ReadonlyMap<
    EventClass,
    readonly ResolvedEventHandler[]
  >;
}

Events are plain classes implementing DomainEvent:

typescript
class OrderPlaced implements DomainEvent {
  readonly occurredAt = new Date();
  constructor(readonly orderId: string) {}
}

Drivers

Memory (@modularityjs/events-memory)

In-process event dispatch. Handlers run in the same process that dispatched the event. For development, testing, and single-instance deployments.

typescript
import { EventsModule } from '@modularityjs/events';
import { EventsMemoryModule } from '@modularityjs/events-memory';

const modules = [EventsModule, EventsMemoryModule];

Redis (@modularityjs/events-redis)

Redis pub/sub event bus for cross-instance broadcast. Requires @modularityjs/redis.

typescript
import { EventsModule } from '@modularityjs/events';
import { EventsRedisModule } from '@modularityjs/events-redis';
import { RedisModule } from '@modularityjs/redis';

const modules = [
  RedisModule.forRoot({ host: 'localhost' }),
  EventsModule,
  EventsRedisModule,
  // or with config:
  // EventsRedisModule.forRoot({ channelPrefix: 'myapp:events:' }),
];

Dispatching Events

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { EventBus } from '@modularityjs/events';

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

  async placeOrder(order: Order): Promise<void> {
    await saveOrder(order);
    await this.events.dispatch(new OrderPlaced(order.id));
  }
}

Handling Events

Handlers use the @OnEvent decorator. Register the handler class in the EventListenersPool:

typescript
import { Injectable } from '@modularityjs/di';
import { EventListenersPool, OnEvent } from '@modularityjs/events';
import { Module } from '@modularityjs/modularity';

@Injectable()
class OrderListener {
  @OnEvent(OrderPlaced, { name: 'log-order' })
  async onOrderPlaced(event: OrderPlaced): Promise<void> {
    console.log(`Order ${event.orderId} placed`);
  }
}

@Module({
  name: 'order-listeners',
  imports: [EventsModule],
  providers: [OrderListener],
  pools: [
    {
      pool: EventListenersPool,
      key: 'order-listener',
      useClass: OrderListener,
    },
  ],
})
class OrderListenersModule {}

Handler Options

typescript
@OnEvent(OrderPlaced, {
  name: 'send-confirmation',  // unique handler name
  order: 10,                  // execution order (lower runs first, default: 0)
  once: true,                 // run on exactly one instance (default: false)
  local: true,                // run only on the dispatching instance (default: false)
})
OptionTypeDefaultDescription
namestringUnique handler name (acts as consumer group for once handlers)
ordernumber0Execution order — lower values run first
oncebooleanfalseWhen true, only one instance executes this handler per event
localbooleanfalseWhen true, handler runs only on the dispatching instance

once and local cannot be combined — a local handler already runs on a single instance.

Dispatch Modes

Configure whether handlers run in parallel (default) or sequentially:

typescript
EventsModule.forRoot({ mode: 'sequential' });

Broadcast Semantics ⚠️

Understanding how events propagate across instances is critical for correct application design.

Memory driver — single-process

With EventsMemoryModule, events are dispatched within the current process only. One dispatch triggers handlers exactly once.

Redis driver — broadcast to all instances

With EventsRedisModule, every dispatched event is broadcast to all running instances via Redis pub/sub. The dispatching instance runs handlers locally, then publishes the event. Every other instance that subscribes to that event type receives it and runs its own handlers.

This means: if you have N instances, every event handler runs N times total — once per instance.

The Scaled Email Problem

Developers coming from single-process frameworks (e.g. PHP's Laravel or Symfony) may be used to patterns like this:

typescript
// WARNING: Without once, this sends one email per running instance!
@OnEvent(OrderPlaced, { name: 'send-order-email' })
async onOrderPlaced(event: OrderPlaced): Promise<void> {
  await this.mailer.send(event.orderId);
}

This works perfectly in a single-process setup or with the memory driver. But with the Redis driver running across 3 instances, the customer receives 3 emails — one from each instance.

This is not a bug. Events are notifications — they announce that something happened. By default, handlers are designed for reactions that are safe to run on every instance, such as:

  • Invalidating a local in-memory cache
  • Updating local state or metrics
  • Logging

Once Handlers

For side effects that must happen exactly once — sending emails, charging payments, calling external APIs — mark the handler with once: true:

typescript
@OnEvent(OrderPlaced, { name: 'send-order-email', once: true })
async onOrderPlaced(event: OrderPlaced): Promise<void> {
  await this.mailer.send(event.orderId);
}

The event still broadcasts to all instances, but only one instance executes this handler. Other handlers on the same event without once continue to run on every instance as usual.

This is analogous to consumer groups in Kafka or RabbitMQ — the handler name acts as the consumer group identifier. For a given event dispatch, only one instance in the group processes it.

How it works with the Redis driver:

When a once handler runs, the Redis driver uses an atomic SET NX (set-if-not-exists) on a key derived from the event ID and handler name. The first instance to claim the key executes the handler; all others skip it. The key expires after a configurable TTL (default: 60 seconds).

typescript
EventsRedisModule.forRoot({ onceDeduplicationTtlMs: 30_000 });

Fail-open behavior: If the Redis SET NX call fails (e.g., connection error), the handler executes rather than being silently dropped. This means in rare failure scenarios a handler may run more than once, but it will never be lost.

Memory driver: once has no effect — single-process drivers already execute each handler exactly once.

Local Handlers

A handler marked with local: true runs only on the instance that dispatched the event. Remote instances that receive the event via pub/sub skip it entirely — no Redis calls, no dedup keys, zero overhead.

typescript
@OnEvent(OrderPlaced, { name: 'enqueue-confirmation', local: true })
async onOrderPlaced(event: OrderPlaced): Promise<void> {
  await this.queue.publish('order.send-confirmation', {
    orderId: event.orderId,
  });
}

This is the ideal pattern for event-to-queue bridges: the local handler publishes to a queue, and the queue's consumer group guarantees exactly-once processing. Unlike once, this doesn't depend on Redis availability for deduplication — the handler simply doesn't run on remote instances.

Use cases:

  • Event-to-queue bridges — publish to a queue from the dispatching instance
  • Request-scoped follow-up work that must stay in the same process
  • Synchronous side effects tied to the dispatching context

Memory driver: local has no effect — single-process drivers only have a local instance.

Choosing the Right Option

Handler typeOptionBehavior
DefaultRuns on every instance (cache invalidation, logging)
onceSimple side effectsRuns on exactly one instance (send email, call webhook)
localEvent-to-queue bridgeRuns only on dispatching instance, queue handles the rest

Rule of thumb: use once: true when any instance can do the work but only one should. Use local: true when the work must happen on the dispatching instance (e.g., publishing to a Queue for retries and dead letter handling).