Mastering NestJS — Building an Effective REST API Backend

Janishar Ali
14 min readJul 10, 2024

--

When we think about developing REST API backend services, NodeJS is a big heavyweight. As you must be familiar, NodeJS is a Javascript runtime, that enables the execution of Javascript code in a non-browser environment. We generally use frameworks like Express and NestJS to create network services.

NestJS has a particular design philosophy, which helps to develop a good project structure. The core framework pieces are as follows:

  1. Controller — It defines the API endpoints and their handling.
  2. Service — This is an optional pattern that decouples a Controller from business logic. The Service class utilizes other components like Model, Cache, and other Services to assist controllers in processing a request.
  3. Module — It helps in solving the dependencies for controllers, services, and other components through importing and exporting instances. Other modules access the exported components to satisfy their dependencies.
  4. APP_GUARD — It applies to all the controller routes globally. Its execution order depends on the position in the provider’s list. When a request is routed to a controller, it first passes through the guards (we do have an option to apply a Guard to a specific controller or controller’s method). Guards are typically used for Authentication and Authorization.
  5. APP_PIPE — It applies global pipes for the transformation and validation of all the incoming requests before they reach a controller route handler.
  6. APP_INTERCEPTOR — It is used to transform the data before it is sent to the client. It can be used to implement common API response formats or response validations.
  7. APP_FILTER — It is used to define central exception handling and sending error responses in a common format.
  8. Annotations — One of the main features of the NestJS framework is the heavy use of annotations. They are used to receive instances through modules, define rules for validation, etc.

You will see how to implement them in the project wimm-node-app. But before that let’s discuss how we define a feature. It’s always a good idea to have feature encapsulation, i.e. keeping most things related to a feature inside a single feature directory. A feature means a common route base url, for example: /blog, /content are two different features. In general, a feature has the following structure:

  1. dto — It represents a request and a response body. We apply the required validations on the DTOs.
  2. schema — It contains a model of the mongo collections (if mongo is being used, else any other ORM models).
  3. controller — It defines route handler functions and more.
  4. service — It assists the controller with business logic

Note: You should clone the GitHub Repo wimm-node-app to move ahead with this article

Let’s see the mentor example from the project

mentor
├── dto
│ ├── create-mentor.dto.ts
│ ├── mentor-info.dto.ts
│ └── update-mentor.dto.ts
├── schemas
│ └── mentor.schema.ts
├── mentor-admin.controller.ts
├── mentor.controller.ts
├── mentor.module.ts
├── mentor.service.ts
└── mentors.controller.ts

create-mentor.dto.ts

import {
IsOptional,
IsUrl,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';

export class CreateMentorDto {
@MinLength(3)
@MaxLength(50)
readonly name: string;

@MinLength(3)
@MaxLength(50)
readonly occupation: string;

@MinLength(3)
@MaxLength(300)
readonly title: string;

@MinLength(3)
@MaxLength(10000)
readonly description: string;

@IsUrl({ require_tld: false })
@MaxLength(300)
readonly thumbnail: string;

@IsUrl({ require_tld: false })
@MaxLength(300)
readonly coverImgUrl: string;

@IsOptional()
@Min(0)
@Max(1)
readonly score: number;

constructor(params: CreateMentorDto) {
Object.assign(this, params);
}
}

mentor.schema.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import mongoose, { HydratedDocument, Types } from 'mongoose';
import { User } from '../../user/schemas/user.schema';

export type MentorDocument = HydratedDocument<Mentor>;

@Schema({ collection: 'mentors', versionKey: false, timestamps: true })
export class Mentor {
readonly _id: Types.ObjectId;

@Prop({ required: true, maxlength: 50, trim: true })
name: string;

@Prop({ required: true, maxlength: 300, trim: true })
title: string;

@Prop({ required: true, maxlength: 300, trim: true })
thumbnail: string;

@Prop({ required: true, maxlength: 50, trim: true })
occupation: string;

@Prop({ required: true, maxlength: 10000, trim: true })
description: string;

@Prop({ required: true, maxlength: 300, trim: true })
coverImgUrl: string;

@Prop({ default: 0.01, max: 1, min: 0 })
score: number;

@Prop({
type: mongoose.Schema.Types.ObjectId,
ref: User.name,
required: true,
})
createdBy: User;

@Prop({
type: mongoose.Schema.Types.ObjectId,
ref: User.name,
required: true,
})
updatedBy: User;

@Prop({ default: true })
status: boolean;
}

export const MentorSchema = SchemaFactory.createForClass(Mentor);

MentorSchema.index(
{ name: 'text', occupation: 'text', title: 'text' },
{ weights: { name: 5, occupation: 1, title: 2 }, background: false },
);

MentorSchema.index({ _id: 1, status: 1 });

mentor.controller.ts

import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Types } from 'mongoose';
import { MongoIdTransformer } from '../common/mongoid.transformer';
import { MentorService } from './mentor.service';
import { MentorInfoDto } from './dto/mentor-info.dto';

@Controller('mentor')
export class MentorController {
constructor(private readonly mentorService: MentorService) {}

@Get('id/:id')
async findOne(
@Param('id', MongoIdTransformer) id: Types.ObjectId,
): Promise<MentorInfoDto> {
const mentor = await this.mentorService.findById(id);
if (!mentor) throw new NotFoundException('Mentor Not Found');
return new MentorInfoDto(mentor);
}
}

mentor.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { Mentor } from './schemas/mentor.schema';
import { User } from '../user/schemas/user.schema';
import { CreateMentorDto } from './dto/create-mentor.dto';
import { UpdateMentorDto } from './dto/update-mentor.dto';
import { PaginationDto } from '../common/pagination.dto';

@Injectable()
export class MentorService {
constructor(
@InjectModel(Mentor.name) private readonly mentorModel: Model<Mentor>,
) {}

INFO_PARAMETERS = '-description -status';

async create(admin: User, createMentorDto: CreateMentorDto): Promise<Mentor> {
const created = await this.mentorModel.create({
...createMentorDto,
createdBy: admin,
updatedBy: admin,
});
return created.toObject();
}

async findById(id: Types.ObjectId): Promise<Mentor | null> {
return this.mentorModel.findOne({ _id: id, status: true }).lean().exec();
}

async search(query: string, limit: number): Promise<Mentor[]> {
return this.mentorModel
.find({
$text: { $search: query, $caseSensitive: false },
status: true,
})
.select(this.INFO_PARAMETERS)
.limit(limit)
.lean()
.exec();
}

...
}

Now lets, see an overview of the project structure.

  1. src — application source code
  2. test — e2e integration tests
  3. disk — submodule: server file storage (for demo purposes only)
  4. keys — RSA keys for JWT token
  5. Rest are the configuration files for building the project

Let’s dive deeper into the src directory

  1. config — We define our environment variables inside the .env file and load them as configs.

database.config.ts

import { registerAs } from '@nestjs/config';

export const DatabaseConfigName = 'database';

export interface DatabaseConfig {
name: string;
host: string;
port: number;
user: string;
password: string;
minPoolSize: number;
maxPoolSize: number;
}

export default registerAs(DatabaseConfigName, () => ({
name: process.env.DB_NAME || '',
host: process.env.DB_HOST || '',
port: process.env.DB_PORT || '',
user: process.env.DB_USER || '',
password: process.env.DB_USER_PWD || '',
minPoolSize: parseInt(process.env.DB_MIN_POOL_SIZE || '5'),
maxPoolSize: parseInt(process.env.DB_MAX_POOL_SIZE || '10'),
}));

2. setup — It defines the database connection and a custom Winston logger

src/setup/database.factory.ts

import { Injectable, Logger } from '@nestjs/common';
import {
MongooseOptionsFactory,
MongooseModuleOptions,
} from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
import { DatabaseConfig, DatabaseConfigName } from '../config/database.config';
import mongoose from 'mongoose';
import { ServerConfig, ServerConfigName } from '../config/server.config';

@Injectable()
export class DatabaseFactory implements MongooseOptionsFactory {
constructor(private readonly configService: ConfigService) {}

createMongooseOptions(): MongooseModuleOptions {
const dbConfig =
this.configService.getOrThrow<DatabaseConfig>(DatabaseConfigName);

const { user, host, port, name, minPoolSize, maxPoolSize } = dbConfig;

const password = encodeURIComponent(dbConfig.password);

const uri = `mongodb://${user}:${password}@${host}:${port}/${name}`;

const serverConfig =
this.configService.getOrThrow<ServerConfig>(ServerConfigName);
if (serverConfig.nodeEnv == 'development') mongoose.set({ debug: true });

Logger.debug('Database URI:' + uri);

return {
uri: uri,
autoIndex: true,
minPoolSize: minPoolSize,
maxPoolSize: maxPoolSize,
connectTimeoutMS: 60000, // Give up initial connection after 10 seconds
socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity,
};
}
}

3. app.module.ts — It loads all the other modules and configurations for our application.

@Module({
imports: [
ConfigModule.forRoot({
load: [
serverConfig,
databaseConfig,
cacheConfig,
authkeyConfig,
tokenConfig,
diskConfig,
],
cache: true,
envFilePath: getEnvFilePath(),
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
useClass: DatabaseFactory,
}),
RedisCacheModule,
CoreModule,
AuthModule,
MessageModule,
FilesModule,
ScrapperModule,
MentorModule,
TopicModule,
SubscriptionModule,
ContentModule,
BookmarkModule,
SearchModule,
],
providers: [
{
provide: 'Logger',
useClass: WinstonLogger,
},
],
})
export class AppModule {}

function getEnvFilePath() {
return process.env.NODE_ENV === 'test' ? '.env.test' : '.env';
}

4. main.ts — It is the first script that is executed when the server runs. It creates a Nest application by loading the AppModule.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { ServerConfig, ServerConfigName } from './config/server.config';

async function server() {
const app = await NestFactory.create(AppModule);

const configService = app.get(ConfigService);
const serverConfig = configService.getOrThrow<ServerConfig>(ServerConfigName);

await app.listen(serverConfig.port);
}

server();

We can now move ahead to explore more about the architecture. The first important thing is to understand the core module. core contains the building block for our architecture.

To make our service consistent we need to define a structure for our request and response. The REST APIs will send 2 types of responses:

// 1. Message Response
{
"statusCode": 10000,
"message": "something",
}

// 2. Data Response
{
"statusCode": 10000,
"message": "something",
"data": {DTO}
}

We will create classes to represent this structure — src/core/http/response.ts

export enum StatusCode {
SUCCESS = 10000,
FAILURE = 10001,
RETRY = 10002,
INVALID_ACCESS_TOKEN = 10003,
}

export class MessageResponse {
readonly statusCode: StatusCode;
readonly message: string;

constructor(statusCode: StatusCode, message: string) {
this.statusCode = statusCode;
this.message = message;
}
}

export class DataResponse<T> extends MessageResponse {
readonly data: T;

constructor(statusCode: StatusCode, message: string, data: T) {
super(statusCode, message);
this.data = data;
}
}

Now, we also have 3 types of request s— Public, Private, and Protected. We define them inside src/core/http/request.ts

import { Request } from 'express';
import { User } from '../../user/schemas/user.schema';
import { ApiKey } from '../../auth/schemas/apikey.schema';
import { Keystore } from '../../auth/schemas/keystore.schema';

export interface PublicRequest extends Request {
apiKey: ApiKey;
}

export interface RoleRequest extends PublicRequest {
currentRoleCodes: string[];
}

export interface ProtectedRequest extends RoleRequest {
user: User;
accessToken: string;
keystore: Keystore;
}

Also, when a DTO is returned from a controller we need to do 2 things:

  1. Response Validation — src/core/interceptors/response.validations.ts
// response-validation.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
InternalServerErrorException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ValidationError, validateSync } from 'class-validator';

@Injectable()
export class ResponseValidation implements NestInterceptor {
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
if (data instanceof Object) {
const errors = validateSync(data);
if (errors.length > 0) {
const messages = this.extractErrorMessages(errors);
throw new InternalServerErrorException([
'Response validation failed',
...messages,
]);
}
}
return data;
}),
);
}

private extractErrorMessages(
errors: ValidationError[],
messages: string[] = [],
): string[] {
for (const error of errors) {
if (error) {
if (error.children && error.children.length > 0)
this.extractErrorMessages(error.children, messages);
const constraints = error.constraints;
if (constraints) messages.push(Object.values(constraints).join(', '));
}
}
return messages;
}
}

2. Response Transformation — Convert a DTO into a response object. src/core/interceptors/response.transformer.ts

import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { DataResponse, MessageResponse, StatusCode } from '../http/response';

@Injectable()
export class ResponseTransformer implements NestInterceptor {
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
if (data instanceof MessageResponse) return data;
if (data instanceof DataResponse) return data;
if (typeof data == 'string')
return new MessageResponse(StatusCode.SUCCESS, data);
return new DataResponse(StatusCode.SUCCESS, 'success', data);
}),
);
}
}

Lastly, we also have to define the exception handling filter at src/core/interceptors/exception.handler.ts

// exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { TokenExpiredError } from '@nestjs/jwt';
import { Request, Response } from 'express';
import { StatusCode } from '../http/response';
import { isArray } from 'class-validator';
import { ConfigService } from '@nestjs/config';
import { ServerConfig, ServerConfigName } from '../../config/server.config';
import { WinstonLogger } from '../../setup/winston.logger';

@Catch()
export class ExpectionHandler implements ExceptionFilter {
constructor(
private readonly configService: ConfigService,
private readonly logger: WinstonLogger,
) {}

catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

let status = HttpStatus.INTERNAL_SERVER_ERROR;
let statusCode = StatusCode.FAILURE;
let message: string = 'Something went wrong';
let errors: any[] | undefined = undefined;

if (exception instanceof HttpException) {
status = exception.getStatus();
const body = exception.getResponse();
if (typeof body === 'string') {
message = body;
} else if ('message' in body) {
if (typeof body.message === 'string') {
message = body.message;
} else if (isArray(body.message) && body.message.length > 0) {
message = body.message[0];
errors = body.message;
}
}
if (exception instanceof InternalServerErrorException) {
this.logger.error(exception.message, exception.stack);
}

if (exception instanceof UnauthorizedException) {
if (message.toLowerCase().includes('invalid access token')) {
statusCode = StatusCode.INVALID_ACCESS_TOKEN;
response.appendHeader('instruction', 'logout');
}
}
} else if (exception instanceof TokenExpiredError) {
status = HttpStatus.UNAUTHORIZED;
statusCode = StatusCode.INVALID_ACCESS_TOKEN;
response.appendHeader('instruction', 'refresh_token');
message = 'Token Expired';
} else {
const serverConfig =
this.configService.getOrThrow<ServerConfig>(ServerConfigName);
if (serverConfig.nodeEnv === 'development') message = exception.message;
this.logger.error(exception.message, exception.stack);
}

response.status(status).json({
statusCode: statusCode,
message: message,
errors: errors,
url: request.url,
});
}
}

We will create a CoreModule in order to apply them. The CoreModule is then added to the AppModule.

import { Module, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ResponseTransformer } from './interceptors/response.transformer';
import { ExpectionHandler } from './interceptors/exception.handler';
import { ResponseValidation } from './interceptors/response.validations';
import { ConfigModule } from '@nestjs/config';
import { WinstonLogger } from '../setup/winston.logger';
import { CoreController } from './core.controller';

@Module({
imports: [ConfigModule],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ResponseTransformer },
{ provide: APP_INTERCEPTOR, useClass: ResponseValidation },
{ provide: APP_FILTER, useClass: ExpectionHandler },
{
provide: APP_PIPE,
useValue: new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
},
WinstonLogger,
],
controllers: [CoreController],
})
export class CoreModule {}

The next important feature is auth, which provides ApiKeyGuard, AuthGuard (Authentication), and RolesGuard (Authorization).

src/auth/guards/apikey.guard.ts — It validated the x-api-key header and its permissions.

import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { HeaderName } from '../../core/http/header';
import { Reflector } from '@nestjs/core';
import { Permissions } from '../decorators/permissions.decorator';
import { PublicRequest } from '../../core/http/request';
import { Permission } from '../../auth/schemas/apikey.schema';
import { AuthService } from '../auth.service';

@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const permissions = this.reflector.get(Permissions, context.getClass()) ?? [
Permission.GENERAL,
];
if (!permissions) throw new ForbiddenException();

const request = context.switchToHttp().getRequest<PublicRequest>();

const key = request.headers[HeaderName.API_KEY]?.toString();
if (!key) throw new ForbiddenException();

const apiKey = await this.authService.findApiKey(key);
if (!apiKey) throw new ForbiddenException();

request.apiKey = apiKey;

for (const askedPermission of permissions) {
for (const allowedPemission of apiKey.permissions) {
if (allowedPemission === askedPermission) return true;
}
}

throw new ForbiddenException();
}
}

src/auth/guards/auth.guard.ts — It validates the JWT Authentication header. It also adds a user and keystore to the request object for the other handlers to receive.

import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ProtectedRequest } from '../../core/http/request';
import { Types } from 'mongoose';
import { AuthService } from '../auth.service';
import { UserService } from '../../user/user.service';

@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector,
private readonly userService: UserService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;

const request = context.switchToHttp().getRequest<ProtectedRequest>();
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException();

const payload = await this.authService.verifyToken(token);
const valid = this.authService.validatePayload(payload);
if (!valid) throw new UnauthorizedException('Invalid Access Token');

const user = await this.userService.findUserById(
new Types.ObjectId(payload.sub),
);
if (!user) throw new UnauthorizedException('User not registered');

const keystore = await this.authService.findKeystore(user, payload.prm);
if (!keystore) throw new UnauthorizedException('Invalid Access Token');

request.user = user;
request.keystore = keystore;

return true;
}

private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

src/auth/guards/roles.guard.ts — It validated the user roles for a given controller or a controller handler.

To specify Roles inside a Controller, we define the decorator at src/auth/decorators/role.decorator.ts

import { Reflector } from '@nestjs/core';
import { RoleCode } from '../schemas/role.schema';

export const Roles = Reflector.createDecorator<RoleCode[]>();

We apply this decorator on a Controller. Example: src/mentor/mentor-admin.controller.ts

@Roles([RoleCode.ADMIN])
@Controller('mentor/admin')
export class MentorAdminController {
...
}

Finally — src/auth/guards/roles.guard.ts

import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from '../decorators/roles.decorator';
import { ProtectedRequest } from '../../core/http/request';

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
let roles = this.reflector.get(Roles, context.getHandler());
if (!roles) roles = this.reflector.get(Roles, context.getClass());
if (roles) {
const request = context.switchToHttp().getRequest<ProtectedRequest>();
const user = request.user;
if (!user) throw new ForbiddenException('Permission Denied');

const hasRole = () =>
user.roles.some((role) => !!roles.find((item) => item === role.code));

if (!hasRole()) throw new ForbiddenException('Permission Denied');
}

return true;
}
}

We can now see the complete picture through a diagram — A request journey through the architecture, resulting in a response.

A couple of productive tools are also added to the architecture. Example — validating and transforming id param string into a MongoId object. Let’s see how we process the mongo id param using MongoIdTransformer.

import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Types } from 'mongoose';
import { MongoIdTransformer } from '../common/mongoid.transformer';
import { MentorService } from './mentor.service';
import { MentorInfoDto } from './dto/mentor-info.dto';

@Controller('mentor')
export class MentorController {
constructor(private readonly mentorService: MentorService) {}

@Get('id/:id')
async findOne(
@Param('id', MongoIdTransformer) id: Types.ObjectId,
): Promise<MentorInfoDto> {
const mentor = await this.mentorService.findById(id);
if (!mentor) throw new NotFoundException('Mentor Not Found');
return new MentorInfoDto(mentor);
}
}

MongoIdTransformer is implemented at src/common/mongoid.transformer.ts

import {
PipeTransform,
Injectable,
BadRequestException,
ArgumentMetadata,
} from '@nestjs/common';
import { Types } from 'mongoose';

@Injectable()
export class MongoIdTransformer implements PipeTransform<any> {
transform(value: any, metadata: ArgumentMetadata): any {
if (typeof value !== 'string') return value;

if (metadata.metatype?.name === 'ObjectId') {
if (!Types.ObjectId.isValid(value)) {
const key = metadata?.data ?? '';
throw new BadRequestException(`${key} must be a mongodb id`);
}
return new Types.ObjectId(value);
}

return value;
}
}

Similarly, we define IsMongoIdObject validation for using in a DTO.

export class ContentInfoDto {
@IsMongoIdObject()
_id: Types.ObjectId;

...
}

IsMongoIdObject is implemented at: src/common/mongo.validation.ts

import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
import { Types } from 'mongoose';

export function IsMongoIdObject(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsMongoIdObject',
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: validationOptions,
validator: {
validate(value: any) {
return Types.ObjectId.isValid(value);
},

defaultMessage(validationArguments?: ValidationArguments) {
const property = validationArguments?.property ?? '';
return `${property} should be a valid MongoId`;
},
},
});
};
}

There are many more subtle things in the architecture serving important functions. You can explore them while you read through the code.

Caching is an important tool for a web server. This project uses Redis for the in-memory cache.

You can find the Redis wrapper at src/cache/redis-cache.ts, which implements the Nest cache-manager. This provides a custom CacheInterceptor via Nest cache API. I will not discuss this code here, since it is an internal implementation and should not be modified.

We then create a factory, service, and a module for enabling redis cache for the app.

src/cache/cache.factory.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CacheConfig, CacheConfigName } from '../config/cache.config';
import { redisStore } from './redis-cache';
import { CacheModuleOptions, CacheOptionsFactory } from '@nestjs/cache-manager';

@Injectable()
export class CacheConfigFactory implements CacheOptionsFactory {
constructor(private readonly configService: ConfigService) {}

async createCacheOptions(): Promise<CacheModuleOptions> {
const cacheConfig =
this.configService.getOrThrow<CacheConfig>(CacheConfigName);
const redisURL = `redis://:${cacheConfig.password}@${cacheConfig.host}:${cacheConfig.port}`;
return {
store: redisStore,
url: redisURL,
ttl: cacheConfig.ttl,
};
}
}

src/cache/cache.service.ts

import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { RedisStore } from './redis-cache';

@Injectable()
export class CacheService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}

async getValue(key: string): Promise<string | null | undefined> {
return await this.cache.get(key);
}

async setValue(key: string, value: string): Promise<void> {
await this.cache.set(key, value);
}

async delete(key: string): Promise<void> {
await this.cache.del(key);
}

onModuleDestroy() {
(this.cache.store as RedisStore).client.disconnect();
}
}

src/cache/redis-cache.module.ts

import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule } from '@nestjs/config';
import { CacheConfigFactory } from './cache.factory';
import { CacheService } from './cache.service';

@Module({
imports: [
ConfigModule,
CacheModule.registerAsync({
imports: [ConfigModule],
useClass: CacheConfigFactory,
}),
],
providers: [CacheService],
exports: [CacheService, CacheModule],
})
export class RedisCacheModule {}

Now, we can use it inside any Controller for caching a request using CacheInterceptor.

import { CacheInterceptor } from '@nestjs/cache-manager';
...

@Controller('content')
export class ContentController {
constructor(private readonly contentService: ContentService) {}

@UseInterceptors(CacheInterceptor)
@Get('id/:id')
async findOne(
@Param('id', MongoIdTransformer) id: Types.ObjectId,
@Request() request: ProtectedRequest,
): Promise<ContentInfoDto> {
return await this.contentService.findOne(id, request.user);
}

...

}

Testing is the first class citizen of any good project. The project extensively implements both unit and integration tests. The code coverage is more than 75%.

I will write in a separate article about effective unit and integration tests. Meanwhile, you can explore the unit tests with {feature}.spect.ts file name, for example — src/auth/auth.guard.spec.ts. Integration tests are located inside the test directory, example — app-auth.e2e-spec.ts

For integration tests, we connect with a test database. The configuration for tests is picked from the .env.test file.

Now, you can explore the repo in detail and I am sure you will find it a good time-spending exercise.

Thanks for reading this article. Be sure to share this article if you found it helpful. It would let others get this article and spread the knowledge. Also, putting a clap will motivate me to write more such articles

Find more about me on janisharali.com

Let’s become friends on Twitter, Linkedin, and Github

--

--

Janishar Ali

Coder 🐱‍💻 Founder 🧑‍🚀 Teacher 👨‍🎨 Learner 📚 https://janisharali.com