
Decorators are a design pattern that can help us to write cleaner code that adheres to the Single Responsibility Principe and Open-closed Principle.
In object-oriented programming, the decorator pattern is a design pattern that allows behavior to be added to an individual object, dynamically, without affecting the behavior of other instances of the same class. Wikipedia
In this post, I’m going to show you how we can create a custom decorator & apply it to a NestJS application. We’re going to utilize @golevelup/nestjs-discovery to make it easy to get access to our classes that have a custom decorator applied.
Example
The full source code for this example is available here.
I’m going to utilize an example taken directly from my latest course: NestJS Microservices: Build a Distributed Job Manager where we want to create job handler classes that contain a method to execute the job. These job handlers will be decorated with a @Job decorator where we can supply decorator metadata about the job.
Using @golevelup/nestjs-discovery we will be able to get access to all of these job handlers at startup and be able to execute the appropriate one with a given request.
Setup
We’ll go ahead and use the NestJS CLI to create a new application & get started.
npm i -g @nestjs/cli@latest
nest new nestjs-decorators
With our project initialized, I’ll go ahead and use the CLI again to generate a new jobs module, controller & service.
nest g module jobs
nest g controller jobs
nest g service jobs
Custom Decorator
With our project setup complete we can now create a new custom decorator to mark our jobs with.
Create a new job.decorator.ts in the src/jobs directory and add the following:
import { SetMetadata } from '@nestjs/common';
interface JobMetdata {
name: string;
description: string;
}
export const JOB_METADATA_KEY = 'job';
export const Job = (meta: JobMetdata) => SetMetadata(JOB_METADATA_KEY, meta);
In here we use the SetMetadata method from NestJS to create the custom decorator with a specific key which can be used to retrieve it later on. We also pass some metadata about the job which will include the name & a description.
Now we want to create some new jobs! Lets start off by creating a common interface they will all follow.
export interface Job {
execute: () => void | Promise<void>;
}
And now I’ll create a couple of sample jobs.
import { Job } from './job.decorator';
import { Job as IJob } from './job.interface';
@Job({
name: 'Scrape',
description: 'Scrape the provided webpage.',
})
export class ScrapeJob implements IJob {
execute() {
console.log('Scraping page...');
}
}
import { Job } from './job.decorator';
import { Job as IJob } from './job.interface';
@Job({
name: 'Scrape',
description: 'Scrape the provided webpage.',
})
export class ScrapeJob implements IJob {
execute() {
console.log('Scraping page...');
}
}
Both of these jobs are implementing our Job interface so they have the common execute method. We apply our custom decorator to both of them and provide some job metadata.
Finally we can start to implement discovery so we can find these jobs at startup and do something with them.
NestJS Discovery
Go ahead and install @golevelup/nestjs-discovery
pnpm i --save @golevelup/nestjs-discovery
Now we import it into our root AppModule.
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@golevelup/nestjs-discovery';
import { JobsModule } from './jobs/jobs.module';
@Module({
imports: [JobsModule, DiscoveryModule],
})
export class AppModule {}
We’re ready to utilize the DiscoveryService inside of our JobService to find our jobs. The DiscoveryService provides a providersWithMetaAtKey function to retrieve all providers by a decorator key.
import {
DiscoveryService,
DiscoveredClassWithMeta,
} from '@golevelup/nestjs-discovery';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { JOB_METADATA_KEY, JobMetdata } from './job.decorator';
import { Job } from './job.interface';
@Injectable()
export class JobsService implements OnModuleInit {
private jobs: DiscoveredClassWithMeta<JobMetdata>[] = [];
constructor(private readonly discoveryService: DiscoveryService) {}
async onModuleInit() {
this.jobs = await this.discoveryService.providersWithMetaAtKey<JobMetdata>(
JOB_METADATA_KEY,
);
}
async getJobs() {
return this.jobs.map((job) => job.meta);
}
async executeJob(name: string) {
const job = this.jobs.find((job) => job.meta.name === name)?.discoveredClass
.instance as Job;
return job.execute();
}
}
The first thing we do here is implement OnModuleInit which is a NestJS lifecycle hook that will ensure our service onModuleInit method gets called. In this method, we use the injected DiscoveryService to look for any NestJS providers with our job decorator applied.
We then store these providers in our class for later use.
The first method getJobs will return all of our job metadata by accessing the metadata property of provider. The second executeJob looks for a job by its name looking at our metadata. We can access the underlying instance of the decorated job by using discoveredClass.instance.
We’ll finally invoke the job execute method by calling job.execute.
Lets finally go ahead and add to our JobsController so we can invoke these methods:
import { Controller, Get, Param, Post } from '@nestjs/common';
import { JobsService } from './jobs.service';
@Controller('jobs')
export class JobsController {
constructor(private readonly jobsService: JobsService) {}
@Get()
getJobs() {
return this.jobsService.getJobs();
}
@Post(':name/execute')
async executeJob(@Param('name') name: string) {
return this.jobsService.executeJob(name);
}
}
Now we’ve exposed GET /jobs to get all of our job metadata and POST :name/execute to execute a single job by its name using a path parameter. Lets open up Postman to try it out!
Of course make sure our server is running first with:
pnpm start:dev
Now I’ll launch request GET http://localhost:3000/jobs and the response is:
[
{
"name": "Scrape",
"description": "Scrape the provided webpage."
},
{
"name": "Weather",
"description": "Fetch the latest weather for your area."
}
]
This is the array of all of our registered jobs in our application which includes the decorator metadata we applied with the job name and description.
FInally I’ll launch POST http://localhost:3000/jobs/Scrape/execute and POST http://localhost:3000/jobs/Weather/execute & check our logs:
Fetching weather...
Scraping page...
Multiple Decorators
Both of our underlying jobs have been invoked! The best part about this approach is that since our jobs are NestJS providers, we can inject dependencies into them if we need. To do this, I’ll update our Job decorator to apply the @Injectable decorator as well.
import { applyDecorators, Injectable, SetMetadata } from '@nestjs/common';
export interface JobMetdata {
name: string;
description: string;
}
export const JOB_METADATA_KEY = 'job';
export const Job = (meta: JobMetdata) =>
applyDecorators(Injectable(), SetMetadata(JOB_METADATA_KEY, meta));
By using applyDecorators we can chain multiple decorators together – in this case we apply Injectable & our custom decorator so we can inject dependencies. Lets update our WeatherJob to inject the DiscoveryService as an example:
import { DiscoveryService } from '@golevelup/nestjs-discovery';
import { Job } from './job.decorator';
import { Job as IJob } from './job.interface';
@Job({
name: 'Weather',
description: 'Fetch the latest weather for your area.',
})
export class WeatherJob implements IJob {
constructor(private discoveryService: DiscoveryService) {}
execute() {
console.log('Fetching weather...');
}
}
Now we can leverage the power of custom decorators & NestJS dependency injection!
Conclusion
There a ton of use cases for decorators in NestJS apps which we can utilize to make our code cleaner & adhere to the single responsibility principle. When you want to wrap existing behavior with some additional functionality or provide metadata to NestJS providers, consider using the decorator pattern & NestJS discovery.
Great analysis, what’s your take on the long-term impact of this