Events
The framework's event system lets one part of your app announce that something happened (a user registered, an order was placed) and let other parts react to it — without those parts knowing about each other. Listeners can run inline or be queued for background processing.
EventDispatcher and the dispatch() helper live in @rudderjs/core. There's no separate package to install.
Defining an event
An event is a plain class. The class name is the event identifier:
// app/Events/UserRegistered.ts
import { User } from '../Models/User.js'
export class UserRegistered {
constructor(public readonly user: User) {}
}Generate stubs with pnpm rudder make:event UserRegistered.
Defining a listener
A listener is a class with a handle(event) method. Each listener handles one event type:
// app/Listeners/SendWelcomeEmail.ts
import type { Listener } from '@rudderjs/core'
import { Mail } from '@rudderjs/mail'
import { UserRegistered } from '../Events/UserRegistered.js'
import { WelcomeEmail } from '../Mail/WelcomeEmail.js'
export class SendWelcomeEmail implements Listener<UserRegistered> {
async handle(event: UserRegistered): Promise<void> {
await Mail.to(event.user.email).send(new WelcomeEmail(event.user))
}
}For background work, dispatch a job inside handle() rather than blocking the request:
async handle(event: UserRegistered) {
await SendWelcomeEmail.dispatch(event.user.id).send()
}Wiring events to listeners
Pass a listener map to eventsProvider({...}) in bootstrap/providers.ts:
// bootstrap/providers.ts
import { eventsProvider } from '@rudderjs/core'
import { UserRegistered } from '../app/Events/UserRegistered.js'
import { SendWelcomeEmail } from '../app/Listeners/SendWelcomeEmail.js'
import { LogRegistration } from '../app/Listeners/LogRegistration.js'
export default [
...(await defaultProviders()),
eventsProvider({
[UserRegistered.name]: [SendWelcomeEmail, LogRegistration],
}),
]The map's keys are class names (use EventClass.name to keep them in sync if the class is renamed). Each event can have multiple listeners — they fire in registration order.
eventsProvider({...}) is a function, not a class — it takes a per-app listener map. It's the one provider that doesn't participate in auto-discovery; you always list it explicitly.
Dispatching
import { dispatch } from '@rudderjs/core'
import { UserRegistered } from '../app/Events/UserRegistered.js'
await dispatch(new UserRegistered(user))dispatch() resolves listeners and runs them in order. Each listener's handle() is awaited in a plain loop, so an unhandled error in one listener stops the remaining listeners and propagates immediately.
Wildcard listeners
Subscribe to every event with '*':
eventsProvider({
'*': [AuditLogger],
})Wildcard listeners receive the event object as their handle() argument — useful for centralized logging, telemetry, or audit trails.
Using the dispatcher directly
For programmatic registration (e.g. inside a service provider's boot()), reach for the global dispatcher:
import { dispatcher } from '@rudderjs/core'
dispatcher.register('UserRegistered', new SendWelcomeEmail())
dispatcher.hasListeners('UserRegistered') // boolean
dispatcher.count('UserRegistered') // numberMost apps stick with eventsProvider({...}) — it's declarative and the listener map is one place to read.
Background dispatch
For listeners that are slow (sending mail, calling APIs, processing data), wrap the work in a queue job and dispatch from the listener:
async handle(event: UserRegistered) {
await SendWelcomeEmailJob.dispatch(event.user.id).send()
}The event fires synchronously; the job runs in the background. A future release may add a built-in static queue = true shortcut on the listener — for now, the explicit job class is the pattern.
Testing
import { EventFake, dispatch } from '@rudderjs/core'
const fake = EventFake.fake()
await someCodeThatDispatches()
fake.assertDispatched('UserRegistered')
fake.assertDispatched('UserRegistered', (e) => e.user.id === '42')
fake.assertDispatchedTimes('UserRegistered', 1)
fake.assertNotDispatched('PaymentFailed') // a specific event did NOT fire
// fake.assertNothingDispatched() // use when you expect zero dispatchesEventFake swaps the dispatcher for the duration of the test — no listeners run, but every dispatch is recorded for assertion.
Pitfalls
- String-keyed listener map drift. Renaming an event class breaks the map's string key silently. Use
[ClassName.name]so the key tracks the class. - Listener throwing inside a request. An unhandled error stops the remaining listeners and propagates out of
dispatch()immediately. Catch inside the listener (andreport(err)) when you want fault isolation or want later listeners to still run. - Long-running synchronous listeners. Anything slow blocks the dispatching code (often a route handler). Queue the work via a job and dispatch the job from
handle().