Skip to main content

Command Palette

Search for a command to run...

Event-Driven Architecture in NestJS

How We Use EventEmitter2 in a Real Production Backend — Patterns, Tradeoffs, and Lessons

Updated
14 min read
Event-Driven Architecture in NestJS
S
Sr Software Engineer - All things web and mobile

Event-driven architecture gets talked about a lot in the context of Kafka, RabbitMQ, and distributed systems. But one of the most practical — and underappreciated — forms of it lives right inside your Node.js process: the EventEmitter.

Node.js ships with EventEmitter as a core primitive. In a NestJS backend it becomes especially powerful when combined with the framework's dependency injection system. No broker, no infrastructure overhead, no ops — just clean pub/sub inside a running process.

This post isn't a tour of the official docs. It's about how we use EventEmitter2 in our production NestJS backend — a system that integrates with Oracle Fusion (HR and procurement), Yardi (leasing), and Firebase Cloud Messaging (push notifications). We'll show real code, explain why we made the decisions we did, and be honest about the tradeoffs.

If you want to understand the EventEmitter fundamentals — .on(), .emit(), .once() — the Node.js official docs are a great starting point. Today we're going one level deeper: production patterns, real gotchas, and the architectural decisions behind them.

What is EventEmitter and why use it in NestJS?

At its core, EventEmitter is a named pub/sub system. You emit a string-named event with a payload. Any registered listener for that name runs. No network, no serialisation, no latency. It's synchronous by default — the listener runs inline before emit() returns.

EventEmitter2 (the package we use) extends this with wildcard patterns, namespaced events, and async listener support. NestJS's @nestjs/event-emitter package wraps it in the DI system so emitters and listeners are injectable providers like anything else.

Our setup in app.module.ts is a single line:

// src/app.module.ts
@Module({
  imports: [
    HttpModule,
    EventEmitterModule.forRoot({ wildcard: true }),
    ScheduleModule.forRoot(),
    // ... other modules
  ],
})
export class AppModule {}

wildcard: true enables EventEmitter2's glob-style patterns — a listener could subscribe to user.* and catch any user event. We set it up for future flexibility. It costs nothing.

That covers the setup — but why use this pattern at all? To understand the motivation, it helps to picture what the alternative looks like.

We work on an enterprise platform that connects employees to internal workflows — things like procurement approvals, HR requests, and leasing processes — all pulling from systems like Oracle Fusion, Yardi, Appian etc. The backend is a NestJS monolith that acts as the integration layer between our frontend and these external systems, plus Firebase for push notifications.

Imagine AuthService needs to notify users when they log in (notification module), update device records on logout (user module), and clear FCM subscriptions (firebase module). Without events, AuthService would import all three of those modules directly. UserService would import the notification module. NotificationService might need UserService. Before long you have a web of cross-module imports that NestJS struggles to resolve — and that every new engineer has to mentally untangle before touching anything.

EventEmitter breaks that web. Instead of AuthService knowing what should happen after a login, it simply announces that a login occurred. The notification module, the user module, and the firebase module each decide independently what to do — without AuthService knowing they exist, and without any of them knowing about each other.

In a system that spans HR, procurement, leasing, notifications, and auth — all integrating with external APIs like Oracle Fusion and Yardi — this separation isn't just nice to have. It's what keeps the codebase navigable as it grows.

Four patterns that we use in production

After building with this approach over time, we've settled into four distinct patterns. Each solves a different problem.

1. Side-effect triggering after auth events

When a user logs out, two things need to happen: their device token should be deregistered, and their Firebase Cloud Messaging topic subscriptions should be removed. Neither of these is the auth module's concern.

The solution: emit a single event and let each module handle its own responsibility.

// src/auth/auth.controller.ts
@Post('logout-mobile')
@ApiBearerAuth()
async logoutFromMobile(@Request() req) {
  const userId = req.user.id;
  const token = req.token;
  await this.authService.logoutToken(token);
  this.eventEmitter.emit('auth.logout', { userId });
  return SUCCESS_RESPONSE;
}

Two completely independent listeners react to this:

// src/user/listeners/authEvent.listener.ts
@Injectable()
export class UserAuthEventListener {
  @OnEvent('auth.logout', { async: true })
  async handleObjectUpdatedEvent(event: ObjectUpdatedEvent) {
    this.userDeviceInfoService.logoutDevice(event.userId);
  }
}

// src/notification/listeners/notificationUserAuthEvent.listener.ts
@Injectable()
export class NotificationUserAuthEventListener {
  @OnEvent('auth.logout', { async: true })
  async handleObjectUpdatedEvent(event: any) {
    const tokens = await this.userDeviceInfoService.getByUserId(event.userId);
    for (const token of tokens) {
      if (!isEmpty(token.deviceToken)) {
        const topics = await this.fcmService.getSubscribedTopics(token.deviceToken);
        for (const topic of topics) {
          await this.fcmService.unsubscribeFromTopic(token.deviceToken, topic);
        }
      }
    }
  }
}

The auth controller doesn't import the notification module. The notification module doesn't import auth. Both react to the same event string from their own module context. This is what clean module separation looks like in practice.

2. Entity lifecycle hooks

After any user is saved or deleted, we want the rest of the system to be able to react — without UserService needing to know what 'reacting' means today, next month, or a year from now.

// src/user/services/user.service.ts
async save(entity: Partial<UserEntity>) {
  const isNew = isEmpty(entity.id);
  const _entity = await this.repo.save(entity);

  if (isNew) {
    this.eventEmitter.emitAsync('user.created', { id: _entity.id })
      .catch((e) => { this.logger.error(e); });
  } else {
    this.eventEmitter.emitAsync('user.updated', { id: _entity.id })
      .catch((e) => { this.logger.error(e); });
  }

  return _entity;
}

async delete(id: number) {
  let _entity = await this.getById(id);
  _entity.isActive = false;
  _entity.isDeleted = true;
  _entity = await this.save(_entity);

  this.eventEmitter.emitAsync('user.deleted', { id: _entity.id })
    .catch((e) => { this.logger.error(e); });

  return _entity;
}

Two deliberate decisions worth calling out here:

  • We use emitAsync so listeners can do async work (DB writes, API calls) properly.

  • We .catch() the promise so a listener failure never surfaces to the HTTP caller. The user was saved. That's the source of truth. Listener failures are logged and monitored separately.

The current listeners just log — they're scaffolded hooks. Adding a webhook, a cache bust, or an analytics call means adding a listener, never touching UserService. That's the architectural payoff.

3. Pipeline chaining for external sync

Our Oracle Fusion sync is the most involved flow in the system. A user's profile needs syncing from several entry points: an HTTP endpoint, a weekly scheduler, admin panels, and external API calls. Rather than duplicating the sync logic in each of those callers, any of them can emit a single event:

// src/user/controllers/user.controller.ts
@Put('/sync-profile')
async syncProfile(@Request() req: any, @Query('first_time') firstTime: boolean) {
  const user = req.user;
  if (user.userType !== UserTypeEnum.EMPLOYEE) {
    throw new BadRequestException('user_not_employee');
  }
  await this.eventEmitter.emitAsync('user.syncProfile', { id: user.id, firstTime });
  return SUCCESS_RESPONSE;
}

A single listener handles the Oracle API call, syncs the profile, updates line managers, and then chains into the next stage by emitting a second event:

// src/processes/hr-request/listeners/hrRequestUserSyncProfileEvent.listener.ts
@OnEvent('user.syncProfile', { async: false })
async handleObjectCreatedEvent(event: ObjectCreatedEvent) {
  const user = await this.userService.getUserAuthDetailsByUserId(event.id);

  const integrationDetails = await this.externalIntegrationService
    .getByOrganizationAndType(user.organizationId, IntegrationTypeEnum.ORACLE_FUSION);

  const profileInfo = await this.hrProcessService
    .getUserDetails(authDetails, integrationDetails);

  await this.userProfileService.syncProfile(user.id, authDetails, profileInfo);
  await this.userProfileService.updateLineManagers(user.username);

  // Chain: trigger task notification sync after profile is up to date
  await this.eventEmitter.emitAsync('user.sync.task.notifications', { id: event.id });
}

The chain: any entry point emits user.syncProfile → one listener owns the Oracle sync logic → on completion it emits user.sync.task.notifications → three listeners (procurement, HR approvals, leasing) each fetch their own pending tasks independently.

The key architectural benefit: five different callers trigger the exact same pipeline by emitting one event. The business logic lives in one place.

4. Audit logging after approval actions

This is the cleanest pattern in the codebase. After a procurement, HR, or leasing approval is submitted to the external API and succeeds, we emit an audit event and a dedicated listener persists the record.

// src/processes/procurement/procurement.process.service.ts
const response = await this.putApiData(url, payload, authDetails, integrationDetails);

// Emit audit event after successful API call
await this.eventEmitter
  .emitAsync('audit.procurement.approval', {
    payload: { taskActionDTO, featureModule, subModule }
  })
  .catch((e) => { this.logger.error(e); });

return response;
// src/processes/procurement/listeners/procurementApprovalAuditEvent.listener.ts
@Injectable()
export class ProcurementApprovalAuditEventListener {
  @OnEvent('audit.procurement.approval', { async: false })
  async handleLeasingAudit(event: AuditEvent) {
    const { taskActionDTO, featureModule, subModule } = event.payload;
    try {
      const approvalEntity = new ProcurementApprovalEntity();
      approvalEntity.userId = taskActionDTO.userId;
      approvalEntity.featureModule = featureModule.feature;
      approvalEntity.subModule = {
        name: subModule.name,
        externalId: subModule.externalId
      };
      approvalEntity.taskId = taskActionDTO.taskId;
      approvalEntity.action = taskActionDTO.action;
      await this.procurementApprovalService.save(approvalEntity);
    } catch (e) {
      this.logger.error(e);
    }
  }
}

What makes this clean: the approval service doesn't import the audit module. The listener is isolated and self-contained. The try/catch inside the listener ensures an audit failure never rolls back or errors the approval itself. Each concern is fully isolated.

The same pattern repeats identically for HR approvals and leasing approvals — three separate audit trails, each owned by their domain listener.

Fan-out via schedulers

EventEmitter pairs naturally with NestJS's scheduler. Our notification system polls for pending tasks every 5 minutes by emitting one event per user — and three listeners react to each emit:

// src/notification/scheduler/checkNotification.scheduler.ts
@Injectable()
export class CheckNotificationScheduler {
  @Cron(CronExpression.EVERY_5_MINUTES, { name: 'CHECK_NOTIFICATION_EVERY_5_MINUTES' })
  async ingestion() {
    const [results] = await this.userService.getUsersForNotificationCheck(page, size);

    for (const user of results) {
      await this.eventEmitter.emitAsync(
        'user.sync.task.notifications',
        { id: user.id }
      );
      await this.userService.updateLastProfileSyncDate(user.id);
    }
  }
}

Each emit of user.sync.task.notifications triggers three independent listeners in parallel — procurement tasks from Oracle Fusion, HR approval tasks from Oracle Fusion, and leasing tasks from Yardi. Each domain manages its own notification persistence. The scheduler knows nothing about any of them.

A weekly scheduler follows the same pattern for full profile syncs:

// src/user/scheduler/syncUserProfileUpdate.scheduler.ts
@Cron('0 6 * * FRI', { name: 'SYNC_PROFILE_EVERY_FRIDAY_MORNING' })
async sync() {
  do {
    const [results] = await this.userService.getUsersForNotificationCheck(page, size);
    for (const user of results) {
      await this.eventEmitter.emitAsync('user.syncProfile', { id: user.id });
      await this.userService.updateLastProfileSyncDate(user.id);
    }
    page += 1;
    totalCount = results?.length;
  } while (totalCount > 0);
}

The scheduler loops over all users, emitting one event each. Every part of the sync pipeline — Oracle profile data, line managers, pending tasks — activates automatically for each user. Adding a new data source means adding a new listener, not editing the scheduler.

Typed event payloads

Event names in our codebase are plain strings. To compensate, we use typed event classes for every payload so at least the shape of the data is explicit and enforced by TypeScript:

// src/common/events/crud.event.ts
export class ObjectCreatedEvent {
  id: number;
  payload: any;
  constructor(id: number) { this.id = id; }
}

export class ObjectUpdatedEvent {
  id: number;
  payload: any;
  constructor(id: number) { this.id = id; }
}

export class ObjectDeletedEvent {
  id: number;
  payload: any;
  constructor(id: number) { this.id = id; }
}
// src/common/events/audit.event.ts
export class AuditEvent {
  id: number;
  payload: any;
  constructor(id: number, payload: string) {
    this.id = id;
    this.payload = payload;
  }
}

These classes serve as a contract between emitter and listener. The listener's @OnEvent handler is typed to ObjectCreatedEvent or AuditEvent, so if you change the payload shape, TypeScript tells you everywhere that needs updating. It's not as strong as a full event registry with string constants, but it's meaningfully better than raw object literals with no type at all.

The async flag: a subtle but important gotcha

This is the thing that catches most teams out when using @nestjs/event-emitter.

When you decorate a listener with @OnEvent('x', { async: false }) but write an async handler, the framework does NOT await the returned Promise. emitAsync() resolves immediately — before the listener's actual work finishes.

// This looks like it waits for the listener to finish...
await this.eventEmitter.emitAsync('user.sync.task.notifications', { id: event.id });

// But if the listener is decorated with { async: false }:
@OnEvent('user.sync.task.notifications', { async: false })
async handleObjectCreatedEvent(event: ObjectCreatedEvent) {
  // All this Oracle Fusion + DB work may not be awaited!
  const tasks = await this.procurementProcessService.getPendingTasks(...);
  for (const task of tasks) {
    await this.notificationService.save(notification);
    await this.notificationService.send(notification);
  }
}

When does this matter?

If you need the listener's side effects to complete before the caller continues, use { async: true }.

If the listener is a true fire-and-forget side effect, { async: false } is fine and intentional.

Our audit listeners use { async: false } deliberately — the approval response shouldn't wait for the audit write.

Our profile sync listeners may benefit from { async: true } for stricter ordering guarantees.

The honest tradeoffs

No architecture pattern is universally correct. Here's where EventEmitter2 works well and where you need to be deliberate.

What you gain

  • No infrastructure. No Redis, Kafka, or RabbitMQ to run locally, in CI, or in production. Events are function calls with indirection.

  • Native NestJS integration. @OnEvent decorators, DI-injected emitters, module-scoped listeners — it all feels like first-class Nest.

  • Natural for side effects. When the primary operation is done and downstream things should happen without coupling modules, EventEmitter is the right size of tool.

  • Low cognitive overhead for simple flows. Grep the event name. Find emitters. Find listeners. For straightforward cases this is faster to reason about than a full message queue topology.

  • Incremental adoption. You can introduce it one event at a time alongside direct service calls, without restructuring the whole app.

What to watch

  • No durability. Events are in-memory. Process restart mid-listener = work lost. We mitigate this with try/catch inside listeners and treating events as best-effort side effects, not guaranteed delivery.

  • Process-local only. All events live in one Node.js process. With multiple app instances, Instance A's events never reach Instance B or C. Schedulers especially need awareness of this in a horizontally scaled deployment.

  • Plain string event names. Typos compile fine. An event emitted with a wrong name silently has no listener. Typed event payload classes (like we use) partially address this — a full constants registry would close the gap entirely.

  • No retry or dead-letter. If a listener throws and you don't catch it inside the listener, the work is gone. Build error handling into every listener that does meaningful work.

When to graduate to a message queue

EventEmitter is the right tool when:

  • You're in a single process (monolith or a microservice that isn't horizontally scaled)

  • Side effects are best-effort and losing one occasionally is acceptable

  • You want zero infrastructure overhead during development and early production

Move to BullMQ, RabbitMQ, or Kafka when:

  • Jobs need to survive a process restart

  • You need retry with configurable backoff on failure

  • You're running multiple instances and need all of them to react

  • You have high-volume pipelines and need concurrency control (e.g. rate-limiting Oracle API calls across a batch of hundreds of users)

  • Separate services or teams need to consume the same event stream

The migration path is clean. The emitter sites barely change:

// Before: in-process EventEmitter
await this.eventEmitter.emitAsync('user.syncProfile', { id: user.id });

// After: BullMQ job queue
await this.syncQueue.add('syncProfile', { id: user.id }, {
  attempts: 3,
  backoff: { type: 'exponential', delay: 2000 }
});

// The worker replaces the listener:
const worker = new Worker('syncProfile', async (job) => {
  await syncProfileHandler(job.data);
}, { connection: redis });

The mental model stays identical — emit, handle, move on. The infrastructure underneath becomes durable and scalable.

Key takeaways

EventEmitter2 in NestJS is a production-grade tool when used within its scope. What we've learned building with it:

  • Use it for module decoupling, not for guaranteed delivery. These are different problems.

  • Treat emitted events as fire-and-forget side effects with error handling inside each listener.

  • Use typed event payload classes — raw object literals make refactoring painful.

  • Understand the async flag before you use it. { async: false } on an async handler is a common source of subtle bugs.

  • Know your scaling ceiling. EventEmitter is process-local. Plan for what happens when you scale horizontally.

  • The pattern's superpower is that any entry point — HTTP handler, scheduler, admin endpoint, external controller — can trigger the same pipeline by emitting one event.

Used deliberately and within its limits, it's one of the most practical architecture patterns in a NestJS backend. And understanding it deeply sets you up to know exactly when you've outgrown it.