iTranslated by AI
Emitting Events with Interceptors in NestJS
Introduction
In an API using NestJS, I was including event emission logic to handle tasks like notifications.
This logic was mainly written inside Controller classes and called whenever needed.
However, with this approach, you need to write event emission logic for various endpoints every single time.
While it's possible to do it this way, I felt it was prone to missing implementations.
Furthermore, I generally intend for these events to be emitted after the entire process is complete.
Therefore, I decided to perform event emission within an interceptor, timing it with the response.
By doing so, I no longer need to write event emission logic in the controller every time.
Furthermore, by adding a little ingenuity to the interceptor, it's possible to define which endpoints should emit events and which shouldn't in a way that minimizes omissions.
In this article, we'll look at that implementation.
Overall Code
Interceptor Part
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { tap } from 'rxjs/operators';
import { LogCreatedEvent } from './log-created.event';
import { AppController } from 'src/app.controller';
const methodsConfig: { [key in keyof AppController]: boolean } = {
getProfile: false,
login: true,
testMethod: true
}
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly eventEmitter: EventEmitter2) { }
intercept(context: ExecutionContext, next: CallHandler) {
const methodName = context.getHandler().name
return next
.handle()
.pipe(
tap(() => {
if (methodsConfig[methodName]) {
this.eventEmitter.emit('log.created', new LogCreatedEvent({ id: 'id', name: 'name' }))
}
})
);
}
}
Event Class
type LogEventData = { id: string, name: string }
export class LogCreatedEvent {
constructor(readonly eventData: LogEventData) { }
}
Controller Class (Some parts omitted)
import { Controller, UseInterceptors } from "@nestjs/common";
import { LoggingInterceptor } from "./interceptor/logging.interceptor";
@UseInterceptors(LoggingInterceptor)
@Controller()
export class AppController {
testMethod() {
return "test";
}
async login() {
/** Login process */
}
getProfile() {
return "profile";
}
}
The code related to this topic is as shown above, but events will not be emitted if you use this code exactly as is.
In reality, you need to register an event handler class and configure the event module in the NestJS Module.
However, since those points are covered in the documentation, I will omit them here.
I've shown the entire code, but before explaining the code itself, let's first briefly look at interceptors and events.
Interceptors in NestJS
What are Interceptors?
NestJS has a feature called interceptors.
In terms of functionality, it allows you to insert arbitrary logic during requests and responses, as shown in the image below.

Quoted from https://docs.nestjs.com/interceptors
These interceptors are built based on Aspect Oriented Programming (AOP).
As a result, the following are possible:
- Binding extra logic before or after method execution
- Transforming the result returned from a route handler
- Transforming the exception thrown from a route handler
- Extending basic function behavior
- Overriding route handlers depending on specific conditions (e.g., for caching purposes)
Now, let's actually implement an interceptor.
Simple Interceptor Implementation
In this section, we will implement an interceptor based on the documentation.
First, create a class for the interceptor.
import { Injectable, NestInterceptor } from "@nestjs/common";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {}
Of course, this won't work on its own, but the interceptor class itself must first have the following definitions:
- Add the
@Injectabledecorator to make it an injectable class. - Implement the
NestInterceptorinterface.
By doing these two things, you establish it as an interceptor class.
Next, we implement the internal method.
The NestInterceptor interface we just implemented has the following definition:
export interface NestInterceptor<T = any, R = any> {
intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<R> | Promise<Observable<R>>;
}
Therefore, you implement the intercept method. This intercept method receives two arguments, and their functions are roughly as follows:
- ExecutionContext, which contains request information and more
- A
CallHandlerthat has ahandlemethod and returns an RxJS Observable
Regarding the return value, you need to return either a Promise or an Observable, but personally, I find it easier to handle by returning an Observable. Or rather, I couldn't find much documentation on returning a Promise, so I wasn't sure what to do. Therefore, I will proceed on the assumption of returning an Observable moving forward.
When returning an Observable, you need to execute the handle method of the CallHandler. The handle method returns an Observable, and the CallHandler holds the response information. Thus, by calling the handle method of the CallHandler, you can return an Observable containing the response information. Based on the above, the minimum implementation of the intercept method is as follows:
intercept(context: ExecutionContext, next: CallHandler) {
return next
.handle()
}
This implementation does nothing since it just returns the response as is. Since there's no point in creating an interceptor with just the above implementation, you would actually add various processes here. To add these processes, let's look at what runs and when. Basically, everything until the handle method of the CallHandler is executed occurs before reaching the Route Handler—that is, during the request phase. Therefore, if you want to modify the request or retrieve request data in the interceptor, you should write it before executing the handle method of the CallHandler. On the other hand, if you want to modify response information, you use the pipe method provided by the Observable. By setting various functions provided by RxJS within this pipe method, you can perform data transformation or execute logic with side effects during the response. While RxJS provides various functions, if you want to transform response data, use the map function as follows:
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(map(data => ({ data })));
}
In this case, the map function receives the response data, so you can process the value accordingly. Also, if you want to execute a side-effect process during the response instead of a transformation, use the tap function within the pipe method as follows:
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
tap(() => console.log(`Output during the response`)),
);
}
This is a brief explanation, but what I've mentioned is also covered in the documentation, so please check there for details.
Event Emission in NestJS
Node.js has a built-in EventEmitter that implements the Observer pattern.
Since it already exists in Node.js, you can use this functionality in NestJS as well.
However, you don't need to implement it exactly like in Node.js; instead, you use the @nestjs/event-emitter package, which internally uses the EventEmitter2 package.
This allows you to easily implement event processing following a certain convention.
The implementation method is roughly as follows:
- Register the event module in the
AppModule. - Create a handler class that receives and processes events.
- Create a class to hold the data received by the handler class.
- Use the
EventEmitter2to emit the event, setting the event name to trigger the handler (from step 2) along with the data class (from step 3).
I initially thought about covering how to write the code in this article, but since it's mostly covered in the documentation, I won't go into detail here.
You can get a good feel for it by looking at the documentation and the sample code.
Code Explanation
Now that I've introduced the main players, I'll explain the processing in the interceptor part, which is the core of this code.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { tap } from 'rxjs/operators';
import { LogCreatedEvent } from './log-created.event';
import { AppController } from 'src/app.controller';
const methodsConfig: { [key in keyof AppController]: boolean } = {
getProfile: false,
login: true,
testMethod: true
}
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly eventEmitter: EventEmitter2) { }
intercept(context: ExecutionContext, next: CallHandler) {
const methodName = context.getHandler().name
return
}
}
It mainly does the following:
- Creating an object that has class methods as properties
- Getting the name of the Route Handler
- Emitting an event within the response
Let's look at each one.
Creating an object with class methods as properties
This implementation is in the following part:
const methodsConfig: { [key in keyof AppController]: boolean } = {
getProfile: false,
login: true,
testMethod: true
}
The intent is to define whether or not a method in the Controller class emits an event.
I want to make the methods of the Controller class the properties, and emit the event only when the value is true.
Additionally, I want to ensure that there are no missing configurations for each endpoint.
To achieve this, by using the type definition { [key in keyof AppController]: boolean }, I enforce that all methods of the target class must be defined as properties.
This allows for mandatory configuration of whether each endpoint emits an event, fulfilling the requirement.
Getting the Route Handler Name
Previously, we created an object that has the endpoint method names as properties. We confirmed that this object serves as the configuration for whether or not to emit an event.
Therefore, we need to obtain information about which method was executed within the interceptor.
This requirement is achieved with the following:
const methodName = context.getHandler().name;
The ExecutionContext holds information about the Route Handler that is about to be executed, which can be retrieved using the getHandler method. This Route Handler to be executed corresponds to a method in the Controller class.
Thus, by accessing the name property of the method, you can obtain the method name.
If you're having trouble visualizing this, check this playground.
Instead of executing the function, by accessing the name property, you should be able to get just the function name.
The process we're performing here is the same.
With this, we have obtained the method name of the controller being executed.
Note that I've confirmed that this process of obtaining the method name works the same way for both REST and gRPC.
After that, by using this as the access key for the object we created earlier, you can determine whether the flag to emit the event is enabled.
Finally, let's look at that part of the process.
Emitting an event within the response
The response data is an Observable, which is the return value of the CallHandler's handle method.
Therefore, we will insert the event emission process by adding configurations to this Observable.
Specifically, it looks like this:
next.handle().pipe(
tap(() => {
if (methodsConfig[methodName]) {
this.eventEmitter.emit(
"log.created",
new LogCreatedEvent({ id: "id", name: "name" })
);
}
})
);
Since event emission is a process with side effects, it is executed within the tap function.
By using if (methodsConfig[methodName]), we branch based on whether the endpoint is intended to emit an event.
If this value is true, it means the endpoint is expected to emit an event, so we emit the event using EventEmitter2.
Afterwards, the pre-defined event handler is triggered, and the target process is executed.
Now that the interceptor is complete, we apply the @UseInterceptors decorator to the controller class used when creating the configuration object, specifying the interceptor class as an argument.
import { Controller, UseInterceptors } from "@nestjs/common";
@UseInterceptors(LoggingInterceptor)
@Controller()
export class AppController {
/** Omitted */
}
With this, a mechanism that keeps event processing independent while preventing configuration omissions is complete.
I hope this serves as a reference for those who want to implement event logic independently.
Concerns about this implementation
Through the previous implementation, we were able to centralize event emission into a single interceptor. While I personally find this convenient, there are three concerns.
The first is that if method names overlap, the event emission settings may not work correctly.
For example, look at the following code.
const test = {
execute: true,
}
const test2 = {
execute: false,
}
const config: {
[key in keyof typeof test]: boolean }
& { [key in keyof typeof test2]: boolean
}
test and test2 have the same property.
If you define a variable config using an intersection type of these extracted property types, you can only define one property as shown below.

Because of this, if identical method names exist, you cannot configure one method to emit an event while the other does not.
To avoid this, you need to either create an interceptor for each Controller class or establish rules such as always adding prefixes to method names.
Additionally, consolidating everything into an interceptor diminishes the benefits of TypeScript's automatic type inference.
This is because interceptors handle all request and response data as the any type.
Therefore, in this case, if you want to use the response value, you must explicitly define the return type as follows:
next.handle().pipe(
tap <
{ responseId: string } >
((res) => {
/** Omitted */
})
);
While this works, you have to check the return value of the endpoint every time, so caution is needed when using response data as an argument.
Finally, due to the nature of interceptors, they can only handle request and response data.
Therefore, you cannot emit an event using an intermediate value from the process.
In such cases, you need to implement event emission logic within the controller or service class.
Emitting events in an interceptor can only be used in limited cases.
As described above, there are advantages and disadvantages to emitting events from an interceptor, so you need to decide whether to use it based on the type of event and requirements.
Conclusion
In this article, we used an interceptor to separate the event emission logic.
By centralizing the event emission, I feel that management has become easier.
Of course, challenges remain, so it cannot be used in all cases, but it was good to find one possible option.
Also, since I was able to deepen my understanding of interceptors, I'm glad I tried it.
If the process introduced this time seems useful, I would be happy if you gave it a try.
Thank you for reading this far.
Discussion