Back to Blog

Event-Driven Architecture in NestJS — When to Use It and Full Implementation

Published: 2026-06-23
Event-Driven Architecture in NestJS — When to Use It and Full Implementation

You are building a SaaS feature that needs to send a welcome email, update analytics, trigger an onboarding sequence, and notify the sales team — all when a user registers.

The naive approach is to call each of those services directly from the registration handler. It works, but now your registration code knows about EmailService, AnalyticsService, OnboardingService, and SalesService. Every new requirement means another import, another call, and another reason to touch that handler. Tight coupling spreads. Deployments get riskier. Testing gets slower.

Event-driven architecture solves this by flipping the dependency: instead of your code calling everything, your code emits a single event and walks away. Let the listeners handle the rest.

This guide walks through a complete NestJS event-driven architecture implementation — from in-process domain events with EventEmitter2 to distributed events with a message broker — so you know exactly when and how to apply each pattern.

What NestJS Event-Driven Architecture Actually Means (and Doesn't)

NestJS event-driven architecture means your services communicate by emitting and reacting to events rather than by calling each other directly. When a user registers, you emit a UserRegistered event. Any number of listeners can react: send an email, update a dashboard, log to audit, call a webhook. The emitter does not know or care what happens next.

What it does not mean: it does not mean you need Kafka, RabbitMQ, or any message broker upfront. Many teams jump to distributed eventing before they need it, adding operational complexity that slows them down. For SaaS applications, most events never leave the process.

The Coupling Problem Events Solve in SaaS

Consider a typical SaaS registration flow:

TypeScript
1async register(dto: CreateUserDto) {
2  const user = await this.prisma.user.create({ data: dto });
3  await this.emailService.sendWelcome(user.email);
4  await this.analyticsService.trackRegistration(user);
5  await this.onboardingService.createSequence(user.id);
6  await this.salesService.notifyLead(user);
7  return user;
8}

Every new requirement means another import and another await. The registration function is tightly coupled to four services. If the email service is down, registration fails. If you want to add a Slack notification, you edit the registration function. If you need to test registration, you mock four services.

Events decouple this:

TypeScript
1async register(dto: CreateUserDto) {
2  const user = await this.prisma.user.create({ data: dto });
3  this.eventEmitter.emit('user.registered', new UserRegisteredEvent(user));
4  return user;
5}

The registration function now has one dependency: the event emitter. It emits the event and returns. Everything else is a listener.

NestJS event-driven architecture implementation — two professionals brainstorming on a whiteboard to plan event flow and system decoupling

Domain Events vs Integration Events — The Distinction That Matters

Most articles treat "events" as one thing. In practice, you need two distinct kinds:

Domain Events

Domain events are internal to your service. They express that something meaningful happened in your domain: UserRegistered, OrderSubmitted, PaymentReceived. They carry minimal data — usually just identifiers and timestamps — because the listeners run in the same process and can query the database for details.

Domain events use EventEmitter2 because they are in-process. No serialization, no network, no durability guarantees. If the process crashes before all handlers finish, the event is lost — but that is acceptable because the database already committed the change that triggered the event.

Integration Events

Integration events are contracts between bounded contexts or microservices. They carry enough data for other services to react without querying your database. UserRegisteredIntegrationEvent might include userId, email, name, plan, and registeredAt because the notification service should not need database access to your user table.

Integration events go through a message broker with persistence guarantees. If the notification service is down when you emit the event, the broker holds it until the consumer is ready.

NestJS EventEmitter2 for In-Process Domain Events

NestJS ships first-party support for EventEmitter2 via the @nestjs/event-emitter package.

Setup

Bash
1npm install @nestjs/event-emitter

Register the module globally:

TypeScript
1import { EventEmitterModule } from '@nestjs/event-emitter';
2import { Module } from '@nestjs/common';
3
4@Module({
5  imports: [
6    EventEmitterModule.forRoot({
7      wildcard: true,
8      delimiter: '.',
9      maxListeners: 10,
10    }),
11  ],
12})
13export class AppModule {}

The wildcard: true option enables pattern-based listening so you can subscribe to user.* and catch every user-related event.

Creating an Event Class

Define events as plain classes with a namespace and eventName for traceability:

TypeScript
1export class UserRegisteredEvent {
2  public readonly namespace = 'user';
3  public readonly eventName = 'user.registered';
4  public readonly timestamp: Date;
5
6  constructor(public readonly userId: string) {
7    this.timestamp = new Date();
8  }
9}

Emitting an Event

Inject EventEmitter2 into your service and emit:

TypeScript
1import { EventEmitter2 } from '@nestjs/event-emitter';
2import { Injectable } from '@nestjs/common';
3
4@Injectable()
5export class UserService {
6  constructor(
7    private readonly prisma: PrismaService,
8    private readonly eventEmitter: EventEmitter2,
9  ) {}
10
11  async register(dto: CreateUserDto) {
12    const user = await this.prisma.user.create({ data: dto });
13    this.eventEmitter.emit(
14      'user.registered',
15      new UserRegisteredEvent(user.id),
16    );
17    return user;
18  }
19}

NestJS event-driven architecture implementation — close-up of JavaScript code displayed on a monitor highlighting programming concepts

Listening for Events

Use the @OnEvent decorator in any provider:

TypeScript
1import { OnEvent } from '@nestjs/event-emitter';
2import { Injectable } from '@nestjs/common';
3
4@Injectable()
5export class WelcomeEmailListener {
6  constructor(private readonly emailService: EmailService) {}
7
8  @OnEvent('user.registered')
9  async handle(event: UserRegisteredEvent) {
10    const user = await this.prisma.user.findUnique({
11      where: { id: event.userId },
12    });
13    await this.emailService.sendWelcome(user.email);
14  }
15}

You can register multiple listeners for the same event. Each runs independently — one failing does not stop the others.

Full Example: UserRegistered Event in a SaaS

Here is the complete flow for a SaaS registration that triggers welcome email, analytics, onboarding, and sales notification — all through events.

Event Definition

TypeScript
1export class UserRegisteredEvent {
2  public readonly namespace = 'user';
3  public readonly eventName = 'user.registered';
4  public readonly timestamp: Date;
5
6  constructor(
7    public readonly userId: string,
8    public readonly email: string,
9    public readonly plan: string,
10  ) {
11    this.timestamp = new Date();
12  }
13}

Emitter (UserService)

TypeScript
1@Injectable()
2export class UserService {
3  constructor(
4    private readonly prisma: PrismaService,
5    private readonly eventEmitter: EventEmitter2,
6  ) {}
7
8  async register(dto: CreateUserDto) {
9    const user = await this.prisma.user.create({ data: dto });
10    this.eventEmitter.emit(
11      'user.registered',
12      new UserRegisteredEvent(user.id, user.email, user.plan),
13    );
14    return user;
15  }
16}

Listeners

TypeScript
1@Injectable()
2export class WelcomeEmailListener {
3  constructor(private readonly emailService: EmailService) {}
4
5  @OnEvent('user.registered')
6  async handle(event: UserRegisteredEvent) {
7    await this.emailService.sendWelcome(event.email);
8  }
9}
10
11@Injectable()
12export class AnalyticsListener {
13  constructor(private readonly analytics: AnalyticsService) {}
14
15  @OnEvent('user.registered')
16  async handle(event: UserRegisteredEvent) {
17    await this.analytics.track('user.registered', {
18      userId: event.userId,
19      plan: event.plan,
20    });
21  }
22}
23
24@Injectable()
25export class OnboardingListener {
26  constructor(private readonly onboarding: OnboardingService) {}
27
28  @OnEvent('user.registered')
29  async handle(event: UserRegisteredEvent) {
30    await this.onboarding.createSequence(event.userId, event.plan);
31  }
32}
33
34@Injectable()
35export class SalesNotificationListener {
36  constructor(private readonly sales: SlackService) {}
37
38  @OnEvent('user.registered')
39  async handle(event: UserRegisteredEvent) {
40    await this.sales.notifyNewSignup(event.email, event.plan);
41  }
42}

The emitter has zero knowledge of these listeners. You can add, remove, or replace listeners without touching the registration code. This is the core benefit of event-driven architecture.

When to Use a Message Broker Instead

NestJS event-driven architecture with a message broker routing integration events between services

EventEmitter2 is perfect for in-process events, but it has three limitations:

  1. No persistence — if the process crashes, unprocessed events are lost
  2. No distribution — events stay within a single Node.js process
  3. No backpressure — if listeners are slow, they block the event loop

You need a message broker when:

  • Durability matters: critical events like InvoiceGenerated or SubscriptionCancelled must survive process restarts
  • Multiple services consume the same event: your billing service, notification service, and data warehouse all need SubscriptionChanged
  • Slow or unreliable consumers: sending email or processing files should not slow down the main request

RabbitMQ with NestJS

NestJS has first-party RabbitMQ transport support through its microservices package, so the producer and consumer below are standard NestJS, not a bespoke integration.

TypeScript
1import { ClientProxy, ClientProxyFactory, Transport } from '@nestjs/microservices';
2import { Injectable } from '@nestjs/common';
3
4@Injectable()
5export class IntegrationEventBus {
6  private client: ClientProxy;
7
8  constructor() {
9    this.client = ClientProxyFactory.create({
10      transport: Transport.RMQ,
11      options: {
12        urls: ['amqp://localhost:5672'],
13        queue: 'integration-events',
14        queueOptions: { durable: true },
15      },
16    });
17  }
18
19  async publish(pattern: string, data: unknown) {
20    await this.client.emit(pattern, data).toPromise();
21  }
22}

Listeners in other services (or even in the same service) subscribe using @MessagePattern or @EventPattern:

TypeScript
1@Injectable()
2export class NotificationConsumer {
3  @EventPattern('user.registered')
4  async handle(data: UserRegisteredIntegrationEvent) {
5    await this.emailService.sendWelcome(data.email);
6  }
7}

Redis Streams (Lighter Alternative)

For many SaaS teams, Redis Streams hit the sweet spot between EventEmitter2's simplicity and RabbitMQ's robustness. Redis is already in the stack for caching, and Streams provide persistence, consumer groups, and at-least-once delivery without the operational overhead of a dedicated message broker.

Kafka

Use Kafka when you need long-term event storage, replayability, or very high throughput. Kafka is overkill for most early-stage SaaS products, but worth considering if you already run it or need event sourcing.

Event Schema Design: Versioning Your Events

Events are contracts. Once another service depends on your event shape, changing it can break consumers. Treat event schemas with the same care as API contracts.

Version Events from Day One

TypeScript
1export class UserRegisteredEventV1 {
2  public readonly eventName = 'user.registered.v1';
3
4  constructor(
5    public readonly userId: string,
6    public readonly email: string,
7    public readonly plan: string,
8  ) {}
9}

When you need to add a field (e.g., referralCode), create UserRegisteredEventV2 and emit both versions during the migration period:

TypeScript
1this.eventEmitter.emit('user.registered.v1', new UserRegisteredEventV1(user.id, user.email, user.plan));
2this.eventEmitter.emit('user.registered.v2', new UserRegisteredEventV2(user.id, user.email, user.plan, referralCode));

Once all consumers migrate to V2, remove the V1 emission.

Schema Registry

For integration events through a message broker, use a schema registry with JSON Schema or Avro:

TypeScript
1const userRegisteredSchema = {
2  $schema: 'http://json-schema.org/draft-07/schema#',
3  type: 'object',
4  required: ['userId', 'email', 'plan', 'timestamp'],
5  properties: {
6    userId: { type: 'string', format: 'uuid' },
7    email: { type: 'string', format: 'email' },
8    plan: { type: 'string', enum: ['free', 'pro', 'enterprise'] },
9    timestamp: { type: 'string', format: 'date-time' },
10  },
11};

Validate events before publishing to catch schema drift early.

Error Handling in Event Handlers

Handling errors and retries in NestJS event handlers during a late-night debugging session

EventEmitter2 runs handlers sequentially for the same event by default. If one handler throws, the remaining handlers for that event still execute — but the error may crash the process if unhandled.

Defensive Handling

Every event handler should wrap its logic in try/catch:

TypeScript
1@OnEvent('user.registered')
2async handle(event: UserRegisteredEvent) {
3  try {
4    await this.emailService.sendWelcome(event.email);
5  } catch (error) {
6    this.logger.error(`Failed to send welcome email for user ${event.userId}`, error.stack);
7    await this.queue.add('retry-welcome-email', { userId: event.userId });
8  }
9}

Retry with Backoff

For critical handlers, send failures to a Bull queue with retry logic instead of discarding:

TypeScript
1@Injectable()
2export class CriticalEventListener {
3  constructor(
4    @InjectQueue('event-retry') private retryQueue: Queue,
5  ) {}
6
7  @OnEvent('invoice.generated')
8  async handle(event: InvoiceGeneratedEvent) {
9    await this.retryQueue.add('process-invoice', event, {
10      attempts: 5,
11      backoff: { type: 'exponential', delay: 2000 },
12    });
13  }
14}

Dead-Letter Queue

When retries are exhausted, route the failed event to a dead-letter queue for manual inspection:

TypeScript
1this.retryQueue.on('failed', async (job, error) => {
2  await this.deadLetterQueue.add('failed-event', {
3    originalEvent: job.data,
4    error: error.message,
5    failedAt: new Date().toISOString(),
6  });
7});

Testing Event-Driven Code

Unit Testing Listeners

Test listeners in isolation by instantiating them with mocked dependencies:

TypeScript
1describe('WelcomeEmailListener', () => {
2  let listener: WelcomeEmailListener;
3  let emailService: MockEmailService;
4
5  beforeEach(() => {
6    emailService = { sendWelcome: jest.fn() };
7    listener = new WelcomeEmailListener(emailService as any);
8  });
9
10  it('sends welcome email on user.registered event', async () => {
11    const event = new UserRegisteredEvent('user-1', 'test@test.com', 'pro');
12    await listener.handle(event);
13    expect(emailService.sendWelcome).toHaveBeenCalledWith('test@test.com');
14  });
15});

Integration Testing with EventEmitter2

Use @nestjs/testing with a real EventEmitter2 instance to test the full chain:

TypeScript
1describe('UserRegistration Flow', () => {
2  let app: INestApplication;
3  let eventEmitter: EventEmitter2;
4
5  beforeAll(async () => {
6    const module = await Test.createTestingModule({
7      imports: [EventEmitterModule.forRoot()],
8      providers: [UserService, WelcomeEmailListener, AnalyticsListener],
9    }).compile();
10
11    app = module.createNestApplication();
12    eventEmitter = app.get(EventEmitter2);
13    await app.init();
14  });
15
16  it('emits user.registered and triggers listeners', async () => {
17    const emailSpy = jest.spyOn(eventEmitter, 'emit');
18
19    const userService = app.get(UserService);
20    await userService.register(mockCreateUserDto);
21
22    expect(emailSpy).toHaveBeenCalledWith(
23      'user.registered',
24      expect.objectContaining({ userId: expect.any(String) }),
25    );
26  });
27
28  afterAll(async () => {
29    await app.close();
30  });
31});

The Tradeoff: Event-Driven Complexity vs Direct Calls

Event-driven architecture trades simplicity for flexibility. You gain decoupling, extensibility, and resilience, but you pay for it in:

  • Indirection: following the code path is harder when you cannot see the call chain
  • Debugging: you cannot just read the emitter to know what happens next
  • Testing: you need integration tests to verify the full event chain
  • Latency: emitting an event is fast, but handlers add total processing time
  • Error recovery: a failed handler does not roll back the emitter's transaction

When direct calls are better: one-to-one interactions where the caller needs a result. For example, validating a password or charging a credit card — these are commands, not events.

When events shine: one-to-many interactions where the caller does not need a response. Registration, subscription changes, data exports — fire-and-forget scenarios where the primary action and side effects are separate concerns.

Real Project Example Where We Applied This

Engineer applying a NestJS event-driven architecture refactor to a SaaS subscription service

In our SaaS platform, the SubscriptionService used to call billing, notifications, feature flags, and analytics directly. Every change to the subscription lifecycle required editing that service. Testing meant mocking five dependencies.

We refactored to event-driven:

  • SubscriptionService emits subscription.created, subscription.updated, subscription.cancelled
  • BillingListener handles invoicing and payment method updates
  • NotificationListener sends confirmation emails and in-app notifications
  • FeatureFlagListener updates seat limits and feature access
  • AuditLogListener records the change for enterprise audit compliance

The result: SubscriptionService dropped from 300 lines to 80. Adding a new subscriber (e.g., sending data to a warehouse) means writing a new listener, not editing the service. Each listener is independently testable. And the subscription code no longer breaks when the email service is down.

Conclusion

Event-driven architecture in NestJS is not about running Kafka in production on day one. It is about structuring your code so that the primary action and its side effects are loosely coupled.

Start with @nestjs/event-emitter for domain events. Add a message broker when you need durability or distribution. Version your event schemas. Handle errors defensively in every listener. And remember: events are for things that happened — if you need a result, use a direct call.

The right event-driven architecture implementation grows with your SaaS. Start simple, add complexity when the coupling actually hurts.

If you have questions about implementing event-driven patterns in your NestJS SaaS, get in touch. We have applied these patterns across production systems and can help you avoid the common pitfalls.

Frequently Asked Questions

Event-driven architecture in NestJS is a pattern where services communicate by emitting and listening to events rather than calling each other directly. NestJS provides built-in support via the @nestjs/event-emitter module (EventEmitter2) for in-process events and integrates with external message brokers like RabbitMQ or Redis for distributed events.

Use EventEmitter2 when events stay within the same process — for example, emitting a UserRegistered event that triggers a welcome email within the same Node.js instance. Use a message broker (RabbitMQ, Redis Streams, Kafka) when events must survive process restarts, reach multiple services, or travel across network boundaries.

Domain events are internal to your service — they express that something meaningful happened in your domain (e.g., OrderSubmitted). Integration events are contracts between bounded contexts or services — they carry enough data for other services to react without needing direct access to your database. In NestJS, domain events use EventEmitter2; integration events go through a message broker.

Each event handler runs independently. If one handler throws, other handlers for the same event still execute. Use try/catch inside handlers and implement retry logic (Bull queues work well for this). For critical handlers, consider dead-letter queues and observability via OpenTelemetry or structured logging.

You can test event-driven code by registering listeners in your test module and asserting they were called after emitting events. Use EventEmitter2's mock or a real instance with @nestjs/testing. For message broker events, use test containers or in-memory alternatives.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio