Skip to content

Event-Driven Architecture

Aman Kumar Singh8 min read
Part 32 of 40From the NestJS Production Guide series
Event-Driven Architecture — article by Aman Kumar Singh

Last time in the NestJS Production Guide, CQRS in NestJS ended on a specific note: publishing an event after a command commits lets you decouple side effects, sending a confirmation email, updating analytics, notifying another module, from the core write. That's the piece worth unpacking. This article covers what the event itself should look like, where it should travel, and how far to take the pattern before it costs you more than it saves.

People often treat event-driven architecture as a single monolithic decision, adopt it or don't. It's really a spectrum. On one end, a service emits an event that a handler in the same process picks up synchronously. On the other, services on separate deployments talk exclusively through a message broker, with no direct HTTP calls between them at all. Most NestJS applications sit somewhere in the middle of that spectrum, and knowing where takes understanding what each step actually buys you.

I'll walk through in-process events first, since that's usually where the pattern starts paying for itself. Then I'll cover what changes once events need to cross a process boundary, and where teams get hurt jumping straight to a message broker for problems that didn't need one.

Why decouple through events at all

The core justification for events is the same one CQRS relies on: code that changes state shouldn't need to know about every downstream consequence of that change. When a subscription upgrades, the write path needs to validate it and update the record, nothing more. It doesn't need to know that billing wants a confirmation email, analytics wants a conversion logged, and sales wants a Slack ping. Cramming all three into the command handler couples unrelated concerns to a single method, and every new consumer means editing code that has nothing to do with what it's adding.

Publishing an event inverts that dependency. The command handler announces what happened and doesn't care who's listening. New consumers register as listeners without touching the code that emits the event, the same reason pub/sub exists at every layer of computing, from DOM events to Kafka topics.

The tradeoff is traceability. A synchronous call is easy to follow in a debugger, one function calls another. An event fired into the void has zero, one, or ten handlers reacting to it, and figuring out what runs after a given action requires good documentation or grepping for @OnEvent. That cost is worth weighing before reaching for events on every state change. It's justified when consumers are genuinely independent concerns owned by different parts of the system, and not simply a way to avoid writing a second line in a service method.

In-process events with @nestjs/event-emitter

For events that stay within a single NestJS process, @nestjs/event-emitter is the right tool. It wraps Node's EventEmitter2 and gives you decorators that fit Nest's dependency injection.

npm install @nestjs/event-emitter
// src/app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';

@Module({
  imports: [EventEmitterModule.forRoot()],
})
export class AppModule {}

Define the event as a plain class, the same way you would a CQRS event:

// src/modules/subscriptions/events/subscription-upgraded.event.ts
export class SubscriptionUpgradedEvent {
  constructor(
    public readonly subscriptionId: string,
    public readonly newPlanId: string,
    public readonly requestedBy: string,
  ) {}
}

Emit it from wherever the state change actually happens:

// src/modules/subscriptions/subscriptions.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SubscriptionsRepository } from './subscriptions.repository';
import { SubscriptionUpgradedEvent } from './events/subscription-upgraded.event';

@Injectable()
export class SubscriptionsService {
  constructor(
    private readonly repository: SubscriptionsRepository,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async upgrade(subscriptionId: string, newPlanId: string, requestedBy: string) {
    const subscription = await this.repository.findById(subscriptionId);
    subscription.upgradeTo(newPlanId);
    await this.repository.save(subscription);

    this.eventEmitter.emit(
      'subscription.upgraded',
      new SubscriptionUpgradedEvent(subscription.id, newPlanId, requestedBy),
    );

    return subscription;
  }
}

Listeners live wherever it makes sense, often in a completely different module:

// src/modules/billing/listeners/subscription-upgraded.listener.ts
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { SubscriptionUpgradedEvent } from '../../subscriptions/events/subscription-upgraded.event';
import { BillingEmailService } from '../billing-email.service';

@Injectable()
export class SubscriptionUpgradedListener {
  private readonly logger = new Logger(SubscriptionUpgradedListener.name);

  constructor(private readonly billingEmailService: BillingEmailService) {}

  @OnEvent('subscription.upgraded')
  async handleUpgrade(event: SubscriptionUpgradedEvent) {
    this.logger.log(`Sending upgrade confirmation for ${event.subscriptionId}`);
    await this.billingEmailService.sendUpgradeConfirmation(event.subscriptionId);
  }
}

By default, EventEmitter2 runs listeners synchronously and in-process. That's fine for fast, non-critical side effects like logging. Two things are worth setting deliberately, though. Pass { async: true } in EventEmitterModule.forRoot() if you want listeners to run on the next tick instead of blocking the emitter. And wrap anything that can fail, an email send, an external API call, in its own error handling. An unhandled exception in one listener shouldn't be able to take down others or bubble back into the code that triggered the event.

Crossing the process boundary

In-process events solve decoupling within one deployable. They don't solve anything once you have more than one service, since EventEmitter2 never leaves the Node process it's running in. That's where a message broker comes in, and it's also where teams tend to over-apply the pattern before they've actually split anything into separate services.

If you're still running a single NestJS deployment, even a large one handling meaningfully high traffic, you don't need a broker. Adding Kafka or RabbitMQ "for scalability" before a second service exists to talk to just adds operational surface area and a new class of incidents with no corresponding benefit. Reach for a broker once you have two independently deployed services that both need to react to the same fact.

When that day comes, the choice comes down mostly to delivery guarantees and operational cost, more than raw throughput.

Redis pub/sub is the lightest option and fits naturally if Redis is already in your stack. It has no persistence: a message published while no subscriber is connected is gone. Fine for ephemeral notifications like "invalidate this cache key," dangerous for anything you can't afford to lose.

BullMQ, also Redis-backed, is a better fit than raw pub/sub for events that must survive a subscriber being briefly offline. Jobs persist in Redis until processed, support retries with backoff, and give you a dead-letter queue for repeated failures. If you're already running Redis, this is often the smallest step up that gets you real delivery guarantees.

RabbitMQ adds proper queuing semantics, exchanges, routing keys, per-consumer acknowledgment, and dead-lettering, at the cost of running another piece of infrastructure. It fits when multiple services need different routing of the same event.

Kafka is built for high-throughput, ordered, replayable event streams. It's the right tool when you genuinely need an event log, audit trail included, but it's the heaviest operationally, and most SaaS backends never generate enough event volume to justify it over a queue-based approach.

Delivery guarantees and idempotency

Once events leave a single process, "at-least-once" delivery is the practical default for any broker with real persistence. Networks partition, consumers crash mid-processing, and retries happen. Exactly-once delivery is achievable in narrow cases but rarely worth engineering for; it's simpler to assume duplicates will happen and design consumers to tolerate them.

That means every event handler that changes state needs to be idempotent. A listener sending a welcome email on user.created should check whether that email already went out, keyed on the event's unique ID rather than trusting it only fires once. A counter-incrementing listener needs a way to detect it already applied a given event, typically by storing processed event IDs in Redis with a TTL matching your retry window.

// src/modules/billing/listeners/idempotency.guard.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class ProcessedEventGuard {
  constructor(private readonly redis: Redis) {}

  async markProcessedOnce(eventId: string): Promise<boolean> {
    const key = `processed-event:${eventId}`;
    const result = await this.redis.set(key, '1', 'EX', 60 * 60 * 24, 'NX');
    return result === 'OK';
  }
}

SET ... NX gives you an atomic check-and-set: if the key already exists, the listener knows it already handled this event and skips reprocessing. This is a small amount of code that saves you from double-charged customers and duplicate emails, which is a disproportionately common source of production incidents in event-driven systems.

Where teams get hurt

The most common mistake is jumping straight to a broker because "event-driven" sounds like the right architecture, without a second service that actually needs to consume anything. Every broker you add is infrastructure you now monitor, patch, and debug at 2am. Start with @nestjs/event-emitter, keep it until you have a genuine reason to cross a process boundary.

The second is ignoring ordering guarantees. Redis pub/sub and most queue setups don't guarantee event order across partitions or consumers. If events must apply in sequence, a subscription upgrade followed immediately by a downgrade, use a broker that preserves order per key (Kafka partitions by key, RabbitMQ with a single consumer per queue) or add application-level sequencing, like a version number on the aggregate that listeners check before applying an update.

The third is silent event loss. An in-memory EventEmitter2 that throws inside a listener, or a broker connection that drops without alerting anyone, can mean a whole category of side effects, welcome emails, webhook notifications, quietly stop firing. Instrument event handlers the same way you'd instrument an API endpoint: log entry and exit, track failure rates, and alert on a dead-letter queue that's growing.

The fourth is schema drift. A payload that changes shape between the version a producer emits and the version a consumer expects breaks silently unless you version events explicitly, subscription.upgraded.v2, or design consumers to tolerate additional or missing fields. This gets harder the more services subscribe to the same event, which is exactly why the outbox pattern, the subject of the next article, matters once you need to guarantee an event actually gets published alongside the write that triggered it.

Key takeaways

  • Events decouple the code that changes state from the code reacting to that change, at the cost of traceability you have to actively manage.
  • `@nestjs/event-emitter` handles in-process decoupling well and should be your default before reaching for a message broker.
  • Add a broker only once you have a genuine second service that needs to consume the same event; don't add one for a single deployment "for scalability."
  • Redis pub/sub has no persistence, BullMQ adds retries and a dead-letter queue on Redis, RabbitMQ adds routing and per-consumer semantics, and Kafka adds an ordered, replayable log at real operational cost.
  • Treat at-least-once delivery as the default and make every event handler idempotent, typically with an atomic check keyed on the event's unique ID.
  • Instrument event handlers for failures and dead letters the same way you instrument API endpoints; silent event loss is one of the most common production incidents in event-driven systems.

Frequently asked questions

Do I need a message broker to build event-driven NestJS applications?

No. `@nestjs/event-emitter` gives you the same decoupling within a single process. Reach for Redis, RabbitMQ, or Kafka only once a second, independently deployed service needs to consume the same event.

What's the difference between @nestjs/event-emitter and @nestjs/cqrs's EventBus?

They overlap in shape but serve different purposes. `EventBus` in `@nestjs/cqrs` is tied to command/query dispatch and typically publishes after a command handler commits. `@nestjs/event-emitter` is a general-purpose event system usable anywhere in the app, independent of whether you're using CQRS at all.

How do I guarantee an event isn't lost if the process crashes right after a database write?

Neither `EventEmitter2` nor a plain broker guarantees this on their own; the write and the publish are separate operations that can fail independently. The outbox pattern solves this by writing the event to the same transaction as the state change, then delivering it separately, the focus of the next article.

Should event payloads include the full entity or just an identifier?

Include enough data for the common case, the fields most listeners need, without the entire entity graph. A listener that needs more can always fetch it by ID. Lean payloads also limit the blast radius of a schema change.

How do I handle ordering when two events must apply in sequence?

Application-level events don't guarantee order by default. Use a broker that supports ordering per key, like Kafka partitioned on an aggregate ID, or add a sequence number to your events so consumers can reject an out-of-order update.

Is Kafka overkill for a typical SaaS backend?

For most SaaS applications, yes. Kafka earns its complexity when you need a genuine event log with replay and high sustained throughput. A Redis-backed queue like BullMQ or RabbitMQ covers the delivery guarantees most teams need with far less operational overhead.

Related articles

Microservices with NestJS — Aman Kumar Singh
Scheduling and Cron Tasks — Aman Kumar Singh
Background Jobs with BullMQ — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.