Events
Contract
@modularityjs/events defines the abstract EventBus and @OnEvent decorator:
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:
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.
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.
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
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:
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
@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)
})| Option | Type | Default | Description |
|---|---|---|---|
name | string | — | Unique handler name (acts as consumer group for once handlers) |
order | number | 0 | Execution order — lower values run first |
once | boolean | false | When true, only one instance executes this handler per event |
local | boolean | false | When 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:
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:
// 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:
@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).
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.
@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 type | Option | Behavior |
|---|---|---|
| Default | — | Runs on every instance (cache invalidation, logging) |
once | Simple side effects | Runs on exactly one instance (send email, call webhook) |
local | Event-to-queue bridge | Runs 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).