Events Guide
Guren provides a simple yet powerful event system for decoupling components in your application. Events allow you to broadcast occurrences in your application that other parts can listen and react to.
Events Guide
Guren provides a simple yet powerful event system for decoupling components in your application. Events allow you to broadcast occurrences in your application that other parts can listen and react to.
The standard vNext path is: import event APIs from @guren/core, register listeners/providers centrally, and keep controllers focused on emitting domain events.
Core Concepts
- Event – A class representing something that happened in your application. Events carry data about the occurrence.
- EventManager – Central hub for registering listeners and emitting events.
- Listener – A function or class that reacts to an event when it's emitted.
Creating Events
Basic Event
Create custom events by extending the Event base class:
import { Event } from '@guren/core'
export class UserRegistered extends Event {
constructor(
public readonly userId: string,
public readonly email: string
) {
super()
}
}
With CLI
Generate event classes using the CLI:
bunx guren make:event UserRegistered
This creates app/Events/UserRegistered.ts:
import { Event } from '@guren/core'
export class UserRegistered extends Event {
constructor() {
super()
}
}
Event Properties
All events have:
timestamp– When the event was created (automatically set)eventName– The event identifier (class name by default)
class OrderPlaced extends Event {
// Custom event name (optional)
static get eventName(): string {
return 'orders.placed'
}
constructor(
public readonly orderId: string,
public readonly total: number
) {
super()
}
}
Registering Listeners
Using Container-bound Facades
The simplest way to work with events without passing the manager around is to create facades from an application container:
import { createFacades } from '@guren/core'
import { UserRegistered } from '@/app/Events/UserRegistered'
const { Events } = createFacades(app.container)
// Register a listener
Events.on(UserRegistered, async (event) => {
console.log(`User ${event.email} registered at ${event.timestamp}`)
})
// Emit the event
Events.emit(new UserRegistered('123', 'user@example.com'))
Direct Instantiation
You can also create an EventManager directly:
import { EventManager } from '@guren/core'
import { UserRegistered } from '@/app/Events/UserRegistered'
const events = new EventManager()
// Register a listener
events.on(UserRegistered, async (event) => {
console.log(`User ${event.email} registered at ${event.timestamp}`)
})
// Emit the event
await events.emit(new UserRegistered('123', 'user@example.com'))
One-Time Listeners
// Listener is automatically removed after first invocation
events.once(ApplicationStarted, (event) => {
console.log(`App started on port ${event.port}`)
})
Listener Priority
Higher priority listeners execute first:
// Runs second (default priority: 0)
events.on(UserRegistered, (e) => console.log('Second'))
// Runs first (higher priority)
events.on(UserRegistered, (e) => console.log('First'), { priority: 10 })
// Runs third (lower priority)
events.on(UserRegistered, (e) => console.log('Third'), { priority: -10 })
Unsubscribing
// Using the subscription handle
const subscription = events.on(UserRegistered, handler)
subscription.unsubscribe()
// Or directly
events.off(UserRegistered, handler)
// Remove all listeners for an event
events.off(UserRegistered)
Listener Classes
For complex listeners, use class-based listeners:
bunx guren make:listener SendWelcomeEmail
// app/Listeners/SendWelcomeEmail.ts
import { Listener } from '@guren/core'
import { UserRegistered } from '@/app/Events/UserRegistered'
import { mail } from '@guren/core'
export class SendWelcomeEmail extends Listener<UserRegistered> {
// The event this listener handles
static event = UserRegistered
// Optional: Queue the listener execution
static shouldQueue = true
static queue = 'emails'
// Optional: Listener priority
static priority = 10
async handle(event: UserRegistered): Promise<void> {
await mail(mailManager)
.to(event.email)
.subject('Welcome!')
.text('Thanks for registering!')
.send()
}
// Optional: Conditionally handle events
shouldHandle(event: UserRegistered): boolean {
// Only send to non-internal emails
return !event.email.endsWith('@internal.example.com')
}
// Optional: Handle failures
async failed(event: UserRegistered, error: Error): Promise<void> {
console.error(`Failed to send welcome email to ${event.email}:`, error)
}
}
Registering Class Listeners
events.listen() reads the class statics: event picks the event, priority orders it, and shouldQueue with queue sends it to a queue (see Queued Listeners). shouldHandle() runs first when defined, and the listener is built once per event. A handle() that throws always propagates; failed() reports it when the class defines one. Inline that happens on the throw itself, and on a queue once the job has used its retries, which is when Job.failed runs.
import { SendWelcomeEmail } from '@/app/Listeners/SendWelcomeEmail'
events.listen(SendWelcomeEmail)
Emitting Events
Sequential Execution
Listeners execute in priority order, one after another:
// Listeners run sequentially
await events.emit(new UserRegistered('123', 'user@example.com'))
Parallel Execution
For faster execution when order doesn't matter:
// Listeners run concurrently
await events.emitParallel(new UserRegistered('123', 'user@example.com'))
Built-in Events
Guren provides several built-in events:
HTTP Events
import { RequestReceived, RequestFinished } from '@guren/core'
// When a request is received
events.on(RequestReceived, (event) => {
console.log(`${event.method} ${event.path}`)
})
// When a request is completed
events.on(RequestFinished, (event) => {
console.log(`${event.method} ${event.path} - ${event.status} (${event.durationMs}ms)`)
})
Authentication Events
import { UserAuthenticated, UserLoggedOut } from '@guren/core'
events.on(UserAuthenticated, (event) => {
console.log(`User ${event.userId} logged in via ${event.guard}`)
})
events.on(UserLoggedOut, (event) => {
console.log(`User ${event.userId} logged out`)
})
Queue Events
import { JobProcessed, JobFailed } from '@guren/core'
events.on(JobProcessed, (event) => {
console.log(`Job ${event.jobName} processed in ${event.durationMs}ms`)
})
events.on(JobFailed, (event) => {
console.error(`Job ${event.jobName} failed:`, event.error.message)
})
Application Events
import { ApplicationStarted, ApplicationShutdown } from '@guren/core'
events.on(ApplicationStarted, (event) => {
console.log(`Server running at ${event.host}:${event.port}`)
})
events.on(ApplicationShutdown, (event) => {
console.log(`Shutting down: ${event.reason}`)
})
Queued Listeners
A listener registered with queue runs in a queue worker instead of inline. The EventServiceProvider wires the events to the queue when the app also binds a QueueManager as queue (QueueServiceProvider, or a provider of your own):
import { createApp, EventServiceProvider, QueueServiceProvider } from '@guren/core'
const app = createApp({
providers: [EventServiceProvider, QueueServiceProvider],
})
// Register a queued listener
events.on(
UserRegistered,
async (event) => {
// This runs in a queue worker
await sendWelcomeEmail(event)
},
{ queue: 'emails' }
)
An emit pushes one job per queued listener, carrying the event's fields. Each listener therefore retries on its own: one that throws does not re-run the ones beside it. The worker rebuilds the event as an instance of its class and runs the listener the message names, so the worker process registers the queued listeners the same way, and in the same order, as the process that emits: the same provider boots in both. A message naming a listener the worker does not have fails rather than running the wrong one.
Two names have to survive the round trip. The worker resolves the event class by name, so pin it against a rename or identifier mangling the way a job pins jobName:
export class UserRegistered extends Event {
static override eventName = 'UserRegistered'
}
Only an own eventName counts, so a subclass does not inherit its parent's pin. A worker that emits nothing and only drains the queue registers the class with events.registerEvent(UserRegistered); events.on(UserRegistered, ...) already does.
Within one manager, an event name belongs to one class. Listeners are keyed by the name, so when a different class reaches on(), listen() or registerEvent() under a name another class holds, the manager warns once, naming both. Left as is, each class's listeners run for the other's emits, and a queued emit is rebuilt as the class registered last. Give one of them a distinct class name or its own eventName. A future major will make this an error.
A listener registered with queue on a manager that cannot reach a queue warns once and runs inline. A future major will throw there instead.
An app that builds its own EventManager wires it in one line:
import { createEventManager, createQueueEventDispatcher } from '@guren/core'
const events = createEventManager()
events.setQueueDispatcher(createQueueEventDispatcher())
The dispatcher sends through the queue driver Job.dispatch() resolves, so the app still needs a QueueManager bound as queue in the container. With none bound, a queued listener warns once and runs inline.
Event Manager Utilities
const events = new EventManager()
// Check if event has listeners
if (events.hasListeners(UserRegistered)) {
await events.emit(new UserRegistered(...))
}
// Get listener count
const count = events.listenerCount(UserRegistered)
// Get all event names with listeners
const eventNames = events.eventNames()
// Get all listeners for an event
const listeners = events.getListeners(UserRegistered)
// Remove all listeners
events.removeAllListeners()
Container Integration
The event system is registered as a singleton via a ServiceProvider. You can resolve it from the container:
// Access via app.container or this.container in providers
const events = container.make('events') // EventManager
events.emit(new UserRegistered('123', 'user@example.com'))
Testing with container.fake()
Swap the event manager with a fake in tests:
// Access via app.container or this.container in providers
import { EventManager } from '@guren/core'
test('application emits events', async () => {
const fakeEvents = new EventManager()
using _ = container.fake('events', fakeEvents)
// All code resolving 'events' from the container (including the facade)
// now uses fakeEvents — no real listeners are triggered
})
Testing
import { describe, test, expect, beforeEach, mock } from 'bun:test'
import { EventManager } from '@guren/core'
import { UserRegistered } from '@/app/Events/UserRegistered'
describe('Events', () => {
let events: EventManager
beforeEach(() => {
events = new EventManager()
})
test('listener is called when event is emitted', async () => {
const listener = mock(() => {})
events.on(UserRegistered, listener)
await events.emit(new UserRegistered('123', 'test@example.com'))
expect(listener).toHaveBeenCalledTimes(1)
})
test('once listener is removed after first call', async () => {
const listener = mock(() => {})
events.once(UserRegistered, listener)
await events.emit(new UserRegistered('123', 'a@example.com'))
await events.emit(new UserRegistered('456', 'b@example.com'))
expect(listener).toHaveBeenCalledTimes(1)
})
test('listeners execute in priority order', async () => {
const order: string[] = []
events.on(UserRegistered, () => order.push('low'), { priority: -10 })
events.on(UserRegistered, () => order.push('default'))
events.on(UserRegistered, () => order.push('high'), { priority: 10 })
await events.emit(new UserRegistered('123', 'test@example.com'))
expect(order).toEqual(['high', 'default', 'low'])
})
test('event data is passed to listener', async () => {
let receivedEvent: UserRegistered | null = null
events.on(UserRegistered, (event) => {
receivedEvent = event
})
await events.emit(new UserRegistered('123', 'test@example.com'))
expect(receivedEvent?.userId).toBe('123')
expect(receivedEvent?.email).toBe('test@example.com')
})
})
Best Practices
Name events after what happened: Use past tense (
UserRegistered,OrderPlaced,PaymentFailed).Keep events immutable: Use
readonlyproperties and don't mutate event data in listeners.One event per occurrence: Each event should represent a single, specific thing that happened.
Keep listeners focused: Each listener should do one thing. Use multiple listeners for multiple side effects.
Use queued listeners for slow operations: Don't block the main flow with email sending, API calls, etc.
Handle listener errors: Wrap listener logic in try-catch or use the
failed()method in class listeners.Use priority sparingly: Most listeners should use the default priority. Only adjust when order truly matters.
Consider parallel emission: Use
emitParallel()when listeners are independent and don't need ordering.