Using Custom Decorators in NestJS
Use full potential of decorators in your NestJS projects. In this post, you will learn how to create custom decorators in NestJS
NestJS is a great framework for developing Node.js server applications. Your code looks clean and descriptive thanks to Dependency Injection ( DI ) system and extensive use of decorators in NestJS. Here is a sample code from a NestJS controller:
// app.controller.ts
@Controller('app')
export class AppController {
constructor(private readonly appService: AppService){}
@Post('auth-user')
authUser(@Body() body: AuthDto){
return this.appService.authUser(body.email, body.password)
}
}
The code just explains itself, we have a router for the path "app" and it accepts a post request at path "auth-user". Request body is parsed into the "body" variable and it passes email and password down to the UserService.
Why You Should Use Decorators?
Point 1: Loose Coupling
The example code above, doesn't involve any http server library such as express or fastify. However, your NestJS application most likely use one of them!
When one part of the application ( e.g. controller ) doesn't need to know about other part of your application ( e.g. express server ) it is said your code is loosely coupled. Loose coupling has advantages / disadvantages over tight coupling, but generally it is preferred where your software needs flexibility.
For example in NestJS you can switch underlying http server without changing single line of your controllers, because they are independent from each other.
Point 2: Cleaner Code
Decorators are simple one-liners that are easy to understand and read. However, if you rely on decorators too much, your methods will sit under numerous decorators. As they say with everything, don't overuse it but if you use it when really needed, they will make your code cleaner.
As a rule of thumb, use decorators for stable functionalities in your app. Don't adopt them early in the development process, but once you feel like you are repeating same code again and again, it is a possible use case for decorators.
Point 3: Reduced Overall Complexity
Surely, decorators are not very simple to implement. You might feel, you have made things even more complex than before. But this is not very accurate, because decorators hide this complexity from your application code, and you only need to implement them once!
Important thing is, you want to move complexity from your application code to another module or a library. This will allow you to focus on the important stuff.
Use Decorators for Stable Parts of Your Application
Once you create a decorator, you don't want to add / remove parameters. Because creating a decorator means you are defining a contract / signature and breaking contracts is a bad practice.
For example, POST decorator from NestJS accepts single parameter for the path, and this contract is very stable.
What will we build today?
In this post, we will create a decorator called HandleEvent. It will allow us to bind event handlers to events. It will be very similar to Post decorator from NestJS if you think about it. Responsibility of Post decorator is to bind certain requests to handlers.
To achieve this we will:
- Create a decorator called: HandleEvent ( actually a decorator factory )
- Create EventHandlerDiscovery, which will discover all the decorated methods within project.
- Create an EventManager, which will be responsible for storing event handlers and firing events.
- Create an EventHandlerService, which will contain the event handler for example event: 'new-subscription'
This example assumes you are already familiar with NestJS, and you need a NestJS project for example codes to work.
Lets write a custom decorator!
Decorators are regular functions
function MyFirstDecorator(constructor){
console.log('I am decorated: ', constructor.name)
}
@MyFirstDecorator
class ExampleClass {
}
There are various kinds of decorators and you can find all of them here: https://www.typescriptlang.org/docs/handbook/decorators.html
To keep things simple we will use method decorators in this post, but if you would like to see more just leave a comment below.
Main takeaway is that, different kinds of decorators accept and return different parameters.
Lets create a MethodDecorator.
// handle-event.decorator.ts
export function HandleEvent(event: string){
return function (target, propertyKey, descriptor){
Reflect.defineMetadata('HANDLE_EVENT', event, descriptor.value);
}
}
We will use this decorator to create a map between event handlers and event names. It will be very similar to NestJS route mapping via @Post('auth-user') decorator.
class EventHandlerService {
@HandleEvent('new-subscription')
newSubscriptionHandler(newSubscriptionEvent){
const { email } = newSubscriptionEvent;
console.log(`[new-subscription]: ${email}`)
this.discordHelper.notify(`You have a new subscriber: ${email}`)
}
}
So, what is going on here?
As you can notice, this time we called the decorator function HandleEvent with 'new-subscription' parameter. We didn't call MyFirstDecorator from earlier example. The truth is, HandleEvent is not a decorator but a decorator factory. It is a function that returns a decorator, i.e. Higher Order Function.
This is a very common pattern in decorators. Because your decorator will most likely require options / parameters to be useful. For HandleEvent decorator to function properly, it needs to know which event are we talking about. So we are accepting the event name as parameter.
Okay, but what does target, propertyKey or descriptor mean?
target is a reference to method's class. In our example it is EventHandlerService. propertyKey is the method's name, in our example it is newSubscriptionHandler, and descriptor is a PropertyDescriptor and to obtain method reference we need to access descriptor.value.
Code below adds metadata to the method, using Reflect object.
Reflect.defineMetadata('HANDLE_EVENT', event, descriptor.value);
Reflect is exported from reflect-metadata package. So lets install it. Though you may not need it if your target is above ES2015
npm install reflect-metadata
There we go, we created our first decorator and as the name suggests, we will use this decorator to map between events and handlers.
Additional Read: How does NestJS handle decorators behind the scenes?
Decorators work by adding metadata to your classes, methods etc.
At runtime your program can read all the metadata added to your code and do queries like: "Get me all methods with a POST metadata"
Result of these queries contain references to actual methods and classed, and that is all we need to make magic happen!
NestJS handles this in two parts:
- Scanning : NestJS detects all the metadata in your code during scanning.
- Discovery or Exploring: NestJS inspects all metadata, and applies its own decorator logics ( routing, injecting, using pipes etc. ).
Luckily, we don't have to write these functionalities from scratch, but it is always good to know what is actually going on behind the scenes.
Detect your decorated methods with Discovery Service
@golevelup team has very convenient packages to use alongside your NestJS project. For this example we will use discovery package, lets install it.
npm install @golevelup/nestjs-discovery
Now we need to discover our methods as follows:
// event-handler.discovery.ts
import { DiscoveryService } from '@golevelup/nestjs-discovery';
@Injectable()
export class EventHandlerDiscovery {
// Inject discovery service from @golevelup
constructor(private readonly discoveryService: DiscoveryService)
public async getEventsToHandlersMap(){
// This returns all the methods decorated with our decorator
const scanResult = await this.discoveryService.
providerMethodsWithMetaAtKey('HANDLE_EVENT')
const eventsToHandlersMap = new Map();
scanResult.forEach(result => {
const event = result.meta['HANDLE_EVENT'];
const handler = result.discoveredMethod.handler;
const that = result.discoveredMethod.parentClass.instance;
const boundHandler = handler.bind(that);
eventsToHandlersMap.set(event, boundHandler);
})
return eventsToHandlersMap;
}
}
Notice we bind the handler to its parent class. This ensures, any reference to this in the handler method points to its own class. Otherwise your handler may not work properly.
const boundHandler = handler.bind(that);
DiscoveryService can only check classes inside NestJS Dependency Injection system. In other words, we need to add EventHandlerService to the providers array of AppModule or any other module. That is simple:
// app.module.ts
@Module({
controllers: [AppController],
providers: [
AppService,
EventHandlerDiscovery,
EventHandlerService,
],
})
export class AppModule {}
Wrapping All Together
Now our decorator and discovery service is setup, there is only one step left. We need to use that eventsToHandlersMap and actually call handler functions from somewhere.
@Injectable()
export class EventManager implements OnModuleInit {
private eventsToHandlersMap;
constructor(
private readonly eventHandlerDiscovery: EventHandlerDisovery
){}
async onModuleInit(){
this.eventsToHandlersMap = await this.eventHandlerDiscovery.getEventsToHandlersMap()
}
public fireEvent(event, data){
const handler = this.eventsToHandlersMap.get(event);
if(handler){
handler(data);
}
}
}
Discovery service works asynchronously, so it is not possible to use it inside constructor. However, we can use OnModuleInit interface from NestJS, which allows us to setup our module ( even run asynchronous code inside it ) before it is marked as ready. This way we can obtain eventsToHandlersMap in our class.
Now any part of our application can use EventManager to fire an event, and they don't need to import EventHandlerService or any other service that use our decorator.
Lets actually fire an event from AppService
// app.service.ts
@Injectable()
export class AppService {
subscribers: { email: string }[] = []
constructor(
private readonly eventManager: EventManager,
){}
subscribe(subscribeDto){
const newSubscriber = { email: subscribeDto.email }
this.subscribers.push(newSubscriber);
this.eventManager.fireEvent('new-subscription', newSubscriber)
}
}
There we go, you implemented a Custom Decorator in NestJS. There are many use cases where decorators fit great and reduce complexity, and reduce code coupling. But it should be used with caution, because if you add decorators for everything, your code may end up even more complex!
AppService who is going to handle the event. Also EventHandlerService has no idea who fired the event. They are now decoupled from each other. Only link between them is EventManager and HandleEvent decorator.
You can always handle your event from another service, such as SubscriptionEventsService, and you can always fire 'new-subscription' event from another service. For example you might add users from another endpoint or another data source.
In the upcoming posts, I will add more detailed use for custom decorators. So, don't forget to subscribe, have a nice one!
Let me know what you think in the comments, thanks for reading.