DEV Community

Cover image for Event-Driven Architecture with @hazeljs/event-emitter
Muhammad Arslan
Muhammad Arslan

Posted on

Event-Driven Architecture with @hazeljs/event-emitter

Decouple your application with decorator-based events, HazelJS-native.

HazelJS · Documentation

Event-driven architecture is one of the most effective ways to keep your codebase flexible and maintainable. When an order is created, you might need to send a confirmation email, update analytics, notify inventory, and log the action. Wiring all of that with direct method calls creates a tangled web of dependencies. The @hazeljs/event-emitter package solves this by letting you emit events and listen for them with a clean, decorator-based API—similar to what NestJS developers know and love.

Why Event-Driven?

In a typical request handler, you might do something like:

// ❌ Tightly coupled - every new side effect requires editing this method
async createOrder(dto: CreateOrderDto) {
  const order = await this.orderRepo.save(dto);
  await this.emailService.sendConfirmation(order);
  await this.analyticsService.track('order_created', order);
  await this.inventoryService.reserve(order.items);
  return order;
}
Enter fullscreen mode Exit fullscreen mode

With events, the same flow becomes:

// ✅ Decoupled - emitters don't know who's listening
async createOrder(dto: CreateOrderDto) {
  const order = await this.orderRepo.save(dto);
  this.eventEmitter.emit('order.created', { orderId: order.id, order });
  return order;
}
Enter fullscreen mode Exit fullscreen mode

Now you can add new listeners (Slack notifications, audit logs, etc.) without touching the order service. Each listener is independent and can be tested in isolation.

Introducing @hazeljs/event-emitter

The @hazeljs/event-emitter package brings event-driven architecture to HazelJS with:

  • @OnEvent() decorator — Declare event listeners on any method
  • EventEmitterService — Inject and emit from anywhere in your app
  • Wildcard support — Listen to patterns like order.* when enabled
  • Async listeners — Full support for async handlers
  • DI integration — Works seamlessly with HazelJS dependency injection

Under the hood, it uses eventemitter2, the same library that powers NestJS's event emitter.

Installation

npm install @hazeljs/event-emitter
Enter fullscreen mode Exit fullscreen mode

Quick Start

1. Import the module

import { HazelModule } from '@hazeljs/core';
import { EventEmitterModule } from '@hazeljs/event-emitter';

@HazelModule({
  imports: [EventEmitterModule.forRoot()],
  providers: [OrderService, OrderEventHandler],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

2. Emit events

Inject EventEmitterService and call emit():

import { Injectable } from '@hazeljs/core';
import { EventEmitterService } from '@hazeljs/event-emitter';

@Injectable()
export class OrderService {
  constructor(private eventEmitter: EventEmitterService) {}

  createOrder(order: Order) {
    // ... save order
    this.eventEmitter.emit('order.created', { orderId: order.id, order });
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Listen with @OnEvent

Create an event handler class:

import { Injectable } from '@hazeljs/core';
import { OnEvent } from '@hazeljs/event-emitter';

@Injectable()
export class OrderEventHandler {
  @OnEvent('order.created')
  handleOrderCreated(payload: { orderId: string; order: Order }) {
    console.log('Order created:', payload.orderId);
    // Send email, update analytics, etc.
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Register listeners

After your app bootstraps, register listeners from your providers:

import { EventEmitterModule } from '@hazeljs/event-emitter';

// Resolves from DI and registers all @OnEvent handlers
EventEmitterModule.registerListenersFromProviders([OrderEventHandler]);
Enter fullscreen mode Exit fullscreen mode

Wildcards and Namespaces

Enable wildcards in forRoot() to listen to event patterns:

EventEmitterModule.forRoot({ wildcard: true })
Enter fullscreen mode Exit fullscreen mode

Then you can do:

@OnEvent('order.*')
handleOrderEvents(payload: unknown) {
  // Catches order.created, order.shipped, order.cancelled, etc.
}
Enter fullscreen mode Exit fullscreen mode

Use ** for multi-level matching (e.g. order.delayed.out_of_stock).

Async Listeners

For async work, use the async option:

@OnEvent('order.created', { async: true })
async handleOrderCreated(payload: OrderCreatedEvent) {
  await this.emailService.sendConfirmation(payload.order);
  await this.analyticsService.track('order_created', payload);
}
Enter fullscreen mode Exit fullscreen mode

By default, errors in listeners are suppressed (logged but not rethrown). Set suppressErrors: false if you want errors to propagate.

Configuration Options

EventEmitterModule.forRoot({
  wildcard: true,        // Enable order.* patterns
  delimiter: '.',       // Namespace delimiter
  maxListeners: 10,     // Max listeners per event
  isGlobal: true,       // Global module (default)
});
Enter fullscreen mode Exit fullscreen mode

Real-World Example

Here's a complete flow: order creation triggers email, analytics, and logging—all decoupled.

// app.module.ts
@HazelModule({
  imports: [EventEmitterModule.forRoot({ wildcard: true })],
  controllers: [OrderController],
  providers: [OrderService, OrderEventHandler, EmailService],
})
export class AppModule {}

// order.service.ts
@Injectable()
export class OrderService {
  constructor(private eventEmitter: EventEmitterService) {}

  async createOrder(dto: CreateOrderDto) {
    const order = await this.saveOrder(dto);
    this.eventEmitter.emit('order.created', { orderId: order.id, order });
    return order;
  }
}

// order-event.handler.ts
@Injectable()
export class OrderEventHandler {
  constructor(private emailService: EmailService) {}

  @OnEvent('order.created', { async: true })
  async handleOrderCreated(payload: { orderId: string; order: Order }) {
    await this.emailService.sendOrderConfirmation(payload.order);
  }

  @OnEvent('order.*')
  logOrderEvent(payload: unknown) {
    console.log('Order event:', payload);
  }
}

// main.ts
const app = new HazelApp(AppModule);
await app.listen(3000);
EventEmitterModule.registerListenersFromProviders([OrderEventHandler]);
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Use descriptive event names — Prefer order.created over orderCreate for namespacing.
  2. Register listeners early — Call registerListenersFromProviders right after app bootstrap.
  3. Type your payloads — Define interfaces for event payloads for better type safety.
  4. Keep handlers focused — Each @OnEvent handler should do one thing; emit more events to chain logic.
  5. Use wildcards sparinglyorder.* is great for logging; avoid ** unless you really need it.

Learn More


@hazeljs/event-emitter — Event-driven architecture, the HazelJS way.

Top comments (0)