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:
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:
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.

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
1npm install @nestjs/event-emitterRegister the module globally:
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:
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:
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}
Listening for Events
Use the @OnEvent decorator in any provider:
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
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)
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
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

EventEmitter2 is perfect for in-process events, but it has three limitations:
- No persistence — if the process crashes, unprocessed events are lost
- No distribution — events stay within a single Node.js process
- No backpressure — if listeners are slow, they block the event loop
You need a message broker when:
- Durability matters: critical events like
InvoiceGeneratedorSubscriptionCancelledmust 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.
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:
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
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:
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:
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

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:
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:
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:
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:
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:
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

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:
SubscriptionServiceemitssubscription.created,subscription.updated,subscription.cancelledBillingListenerhandles invoicing and payment method updatesNotificationListenersends confirmation emails and in-app notificationsFeatureFlagListenerupdates seat limits and feature accessAuditLogListenerrecords 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.
