NestJS: benefits, cheatsheet and tips

Jul 12, 2024

NestJS is a great library for creating a web service. It strikes a nice balance between a comprehensive set of solutions to common problems, without feeling like a heavy framework. This article explains many of its features, before detailing some tips around things like seeding and AWS Lamda deployment.

Features / Cheatsheet

This article assumes you have a basic understanding of NestJS and it's modular approach. The items below highlight various secondary features that address some age old issues, depending on what framework you're coming from.

A summary of features include:

  • Quick wins: CORS, environment variables, static directories

  • Payload validation, custom guards and decorators for clean validation

  • Events and queues for module communication using

CORS

A good example of how NestJS can make common things simple; you can enable CORS as simply as:

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors();
  await app.listen(3000);
}
bootstrap();

Environment Variables

To make process.env.VAR available across your service, import the ConfigModule as follows:

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AppConfigService } from "./app-config/app-config.service";

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ".env",
    }),
  ],
  controllers: [],
  providers: [AppConfigService],
})
export class AppModule {}

Payload Validation

NestJS provides the ValidationPipe that will automatically validate your body using a specified DTO model. This avoids littering your controllers and services with validation logic. Furthermore, as discussed below, you can write your own decorators to ensure any specified IDs are valid.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // automatically validate incoming requests
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

Guards and Decorators

To further avoid having validation logic spill into your controller and services, you can add your own guards that utilise your own services.

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  NotFoundException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { isValidUUIDV4 } from "is-valid-uuid-v4";
import { SessionService } from "~/session/session.service";
import { WorkoutService } from "~/workout/workout.service";

/**
 * Ensure the workout exists and it's owned by the current user.
 */
@Injectable()
export class WorkoutOwnershipGuard implements CanActivate {
  constructor(
    private workoutService: WorkoutService,
    private sessionService: SessionService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();

    const workoutId = request.params.workoutId;
    if (!isValidUUIDV4(workoutId)) {
      throw new NotFoundException("Workout ID not valid");
    }
    const workout = await this.workoutService.findOneById(workoutId);
    if (!workout) {
      throw new NotFoundException("Workout not found");
    }

    const user = await this.sessionService.fetchSessionUser();
    if (workout.ownerId !== user.id) {
      throw new NotFoundException("Workout not found");
    }

    return true;
  }
}

Multiple Entry Points

Events

Queues

Tips

Automatically Exclude Sensitive Data

To exclude particular fields from your Entities being returned in your service responses, you can add @Exclude as a decorator, as follows:

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { Exclude } from 'class-transformer';

@Entity()
export class YourEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Exclude()
  sensitive: string;

  ...
}

To have these enforced, you need to call plainToInstance in each endpoint:

import { Controller, Get, Param, Post, Body } from '@nestjs/common';
import { YourEntityService } from './your-entity.service';
import { plainToInstance } from 'class-transformer';
import { YourEntity } from './your-entity.entity';

@Controller('your-entity')
export class YourEntityController {
  constructor(private readonly yourEntityService: YourEntityService) {}

  @Get(':id')
  async getEntity(@Param('id') id: number): Promise<YourEntity> {

To avoid the risk of missing one of these conversions and accidentally exposing data, we can introduce a bit of "magic" to do this automatically. We can create the following interceptor:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { plainToInstance } from "class-transformer";

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        if (Array.isArray(data)) {
          return data.map((item) => plainToInstance(item.constructor, item));
        } else if (data && typeof data === "object") {
          return plainToInstance(data.constructor, data);
        }
        return data;
      }),
    );
  }
}

This can be added to the app like so:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TransformInterceptor } from './transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new TransformInterceptor());
  await app.listen(3000);
}
bootstrap();

I would typically advise against this type of magic, as it can make the software harder to debug and understand. However, given it helps address a potential security risk, I think the tradeoff is justifiable in this instance.

Databased-backed Decorators

Writing your own decorators can nicely encapsulate your validations, particularly for any DTO models (as described above). An example of "the specified ID exists" can be implemented like so:

// location-equipment.dto.ts
import { IsNumber, IsUUID } from "class-validator";
import { LocationEquipmentEntity } from "../location-equipment.entity";
import { EquipmentExists } from "~/equipment/decorators/equipment-exists.decorator";

export class CreateLocationEquipmentDto
  implements Partial<LocationEquipmentEntity>
{
  @IsUUID()
  @EquipmentExists()
  equipmentId: string;
}

import {
  registerDecorator,
  ValidationOptions,
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
} from "class-validator";
import { Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";
import { InjectDataSource } from "@nestjs/typeorm";
import { isValidUUIDV4 } from "is-valid-uuid-v4";

import { EquipmentEntity } from "~/equipment/equipment.entity";

@ValidatorConstraint({ async: true })
@Injectable()
export class EquipmentExistsConstraint implements ValidatorConstraintInterface {
  constructor(@InjectDataSource() private readonly dataSource: DataSource) {
    // added to help debug how many instances exists
    console.log("Loaded EquipmentExistsConstraint");
  }

  async validate(equipmentId: any, args: ValidationArguments) {
    if (!isValidUUIDV4(equipmentId)) {
      return false;
    }
    const equipment = await this.dataSource
      .getRepository(EquipmentEntity)
      .findOne({ where: { id: equipmentId } });

    return !!equipment;
  }

  defaultMessage(args: ValidationArguments) {
    return "Equipment does not exist.";
  }
}

export function EquipmentExists(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      constraints: [],
      validator: EquipmentExistsConstraint,
    });
  };
}

Please note, in order for this to work with DTO models, you'll need to do the following:

  • Add the decorator to the relevant module. For example:

@Module({
  imports: [
    DatabaseModule,
    TypeOrmModule.forFeature([LocationEntity, LocationEquipmentEntity]),
    EquipmentModule,
  ],
  controllers: [
    LocationController,
    LocationEquipmentController,
  ],
  providers: [LocationService, EquipmentExistsConstraint],
  exports: [LocationService],
})
export class LocationModule {}
  • Ensure decorators use the NestJS dependency injection by adding the following in your entry file: useContainer(app.select(AppModule), { fallbackOnErrors: true }). A complete example might look as follows:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // required to allow class-validator to use NestJS dependency injection
  useContainer(app.select(AppModule), { fallbackOnErrors: true });

  // automatically validate incoming requests
  app.useGlobalPipes(new ValidationPipe());

  // Enable CORS
  app.enableCors();
  await app.listen(3000);
}
bootstrap();

Deploying to Lambda