Poyters
38 min read

NestJS cheat sheet

Hello!

A cheat sheet covering the most crucial and complex concepts from NestJS! Come back here to recall some specific NestJS concepts, especially the ones that become important when building larger and more complex applications. ^_^

This is not meant to be an introduction to NestJS. I will mostly skip the basic concepts (or move through them as fast as I can) and focus instead on how NestJS works under the hood, how its abstractions interact with each other, and what problems they solve.

1. Core Concepts

1.1. What is NestJS?

NestJS is a framework for building server-side applications with Node.js and TypeScript, built on top of Express.js by default while also supporting Fastify as an alternative HTTP adapter. It provides an opinionated architecture built around modules, dependency injection, decorators, and providers, making it easier to build structured and maintainable backend applications as they grow.

1.2. NestJS vs. other backend technologies

NestJS is a good choice when you want to build a structured backend using TypeScript and take advantage of the Node.js ecosystem. Go can be a better fit when simplicity, low resource usage, and predictable performance are priorities, Spring Boot when you want the mature Java ecosystem and enterprise tooling, while Phoenix and Elixir are especially interesting for highly concurrent, real-time, and fault-tolerant systems.

1.3. I/O

Node.js is well suited for I/O-heavy applications because its event-driven, non-blocking architecture allows a small number of threads to handle many concurrent I/O operations. This is not unique to Node.js, however Go achieves similar scalability with goroutines, while modern Java can use virtual threads to efficiently handle large numbers of concurrent I/O operations.

For example, when an API receives a request that requires fetching data from PostgreSQL, Node.js does not have to sit idle while waiting for the database to respond:

await
PostgreSQL responds

Node.js handling a PostgreSQL query

Common I/O operations include:

This makes Node.js a strong choice for applications that spend most of their time waiting for external systems rather than performing CPU-intensive computations. The important distinction is that Node.js is not necessarily faster at performing the I/O itself - its strength is handling many concurrent operations efficiently while those operations are waiting to complete.

Real-time example: Imagine an API receiving hundreds of requests where each request needs to fetch data from a database or an external API. Node.js can keep handling new requests while previous ones are waiting for their I/O operations to complete, instead of blocking the application while waiting.

This does not make individual requests magically faster - a request still has to wait for the database, network, or other external system to respond. The advantage is that this waiting does not prevent Node.js from making progress on other requests.

2. Dependency Injection

Dependency Injection (DI) is one of the core concepts of NestJS. Instead of creating dependencies manually inside a class, NestJS manages their creation and provides them where they are needed.

With dependency injection, a class declares what it needs through its constructor, and NestJS's IoC container resolves and injects those dependencies automatically.

@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}
}

2.1. Inversion of Control

Inversion of Control (IoC) means that a class does not control how its dependencies are created. Instead, this responsibility is moved to the NestJS container, which creates and connects the required objects.

2.2. Providers

Providers are the main building blocks used for implementing application logic in NestJS. A provider can be a class, value, factory, or existing provider that NestJS can manage and inject into other components through its IoC container.

The most common providers are services, which are typically decorated with @Injectable() and registered in a module:

@Injectable()
export class UsersService {
  // ...
}

Once registered as a provider, NestJS can create its instance and inject it wherever it is required:

@Injectable()
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
}

2.2.1. Scopes

Providers are singletons by default. This means that NestJS creates one instance of a provider and reuses that instance throughout the application.

NestJS provides three provider scopes:

The default singleton scope is usually preferred because it is more efficient and avoids unnecessary object creation. Request-scoped providers can be useful when a provider needs access to request-specific state, but they can also increase the cost of handling requests because their dependencies may become request-scoped as well.

2.2.2. Custom providers

Custom providers give you more control over how NestJS resolves a dependency.

NestJS provides four main ways to define custom providers:

useClass

Tells NestJS which class should be instantiated for a given token:

{
  provide: 'PAYMENT_SERVICE',
  useClass:
    process.env.PAYMENT_PROVIDER === 'stripe'
      ? StripePaymentService
      : PayPalPaymentService,
}

useValue

Registers an existing value or object as a provider:

{
  provide: 'APP_CONFIG',
  useValue: {
    apiUrl: 'https://api.example.com',
  },
}

This is particularly useful for configuration objects, constants, or mocks in tests.

useFactory

Creates a provider using a factory function. The value is created dynamically when the provider is instantiated, and the factory can receive other providers as dependencies:

{
  provide: 'DATABASE',
  useFactory: (configService: ConfigService) => {
    return createDatabase(configService.get('DATABASE_URL'));
  },
  inject: [ConfigService],
}

useExisting

Creates an additional token that points to an already registered provider instead of creating another instance:

{
  provide: 'PAYMENT_SERVICE',
  useExisting: StripePaymentService,
}

This is useful when the same provider should be available under multiple injection tokens while still referring to the same instance.

2.3. Injection tokens

Injection tokens are keys used by NestJS to identify dependencies in the DI container.

They are useful when you don’t want to depend directly on a specific class and instead want to inject an implementation dynamically:

{
  provide: 'PAYMENT_SERVICE',
  useClass: StripePaymentService,
}

Then inject it using the same token:

constructor(
  @Inject('PAYMENT_SERVICE')
  private readonly paymentService: PaymentService,
) {}

The token can be a string, symbol, or class. This is especially useful when one abstraction can have multiple implementations or when injecting values that are not classes.


3. Modules

Core mechanism for organizing the application and defining dependency boundaries. Modules are not just folders - they control the dependency graph and encapsulation.

3.1. Module encapsulation

Providers are private to the module by default. Use exports to expose them.

Think of exports as the module’s public API.

3.2. Imports and exports

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

3.3. Global modules

@Global() makes a module available across the application without explicitly importing it everywhere.

@Global()
@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}

3.4. Dynamic modules

Dynamic modules are modules that can be configured when they are imported.

They are useful for reusable modules that need configuration or need to create providers based on that configuration.

For example:

DatabaseModule.forRootAsync({
  useFactory: (configService: ConfigService) => ({
    url: configService.get("DATABASE_URL"),
  }),
  inject: [ConfigService],
});

3.5. ModuleRef

ModuleRef gives programmatic access to Nest’s DI container.

constructor(private readonly moduleRef: ModuleRef)

const service = this.moduleRef.get(MyService);

Useful for:


4. Serialization

Serialization is the process of converting objects into a format suitable for sending over the network, usually JSON.

4.1. ClassSerializerInterceptor

Uses class-transformer to transform class instances before returning them from controllers.

@UseInterceptors(ClassSerializerInterceptor)
@Controller("users")
export class UsersController {}

Common decorators: @Exclude(), @Expose(), @Transform().

4.2. Serialization groups

Allow different representations of the same class depending on context.

@Expose({ groups: ['admin'] })
internalId: string;

@SerializeOptions({ groups: ['admin'] })

Useful for public vs admin responses.

4.3. Excluding properties

Use @Exclude() for sensitive or internal fields:

@Exclude()
passwordHash: string;

Prefer dedicated response DTOs over exposing ORM entities directly.

4.4. Transforming responses

Use @Transform() to change the serialized representation:

@Transform(({ value }) => value.toISOString())
createdAt: Date;

4.5. Summary


5. Request Lifecycle

Nest processes a request through several layers. The key is knowing what runs when and what each layer is responsible for.

5.1. Request flow

For a typical HTTP request:

  1. Middleware - runs first; can modify the request or response.
  2. Guards - decide whether the request is allowed to continue.
  3. Interceptors (before) - execute before the handler.
  4. Pipes - validate and transform handler arguments.
  5. Controller / Route Handler - executes the actual application logic.
  6. Interceptors (after) - process the handler’s result.
  7. Exception Filters - handle uncaught exceptions.

A useful mental model:

Typical NestJS HTTP request lifecycle

5.2. Middleware

Runs before the route handler pipeline.

Good for generic request processing:

Doesn’t have access to Nest’s ExecutionContext or route-specific metadata.

5.3. Guards

Run after middleware, before interceptors/pipes/handler.

Main purpose: authorization / deciding whether the request can proceed.

canActivate(context: ExecutionContext): boolean

5.4. Interceptors

Wrap the handler execution. They can run before and after the handler.

Useful for:

return next.handle().pipe(map((data) => transform(data)));

5.5. Pipes

Run before the handler receives the arguments.

Main responsibilities:

@Get()
find(@Param('id', ParseIntPipe) id: number) {}

5.6. Exception filters

Handle exceptions that weren’t already handled.

They can customize:

@Catch(HttpException)
export class HttpExceptionFilter {}

6. Metadata and custom decorators

NestJS uses metadata + decorators heavily to attach information to classes, methods, and parameters, and then read that information at runtime.

6.1. Metadata

Metadata is information attached to a class/method/property using decorators.

@SetMetadata('roles', ['admin'])
@Get()
findAll() {}

6.2. Reflector

Reflector is Nest’s convenient way to read metadata.

const roles = this.reflector.get<string[]>("roles", context.getHandler());

Very common in authorization:

const roles = this.reflector.getAllAndOverride<Role[]>("roles", [
  context.getHandler(),
  context.getClass(),
]);

Reflector provides different methods for reading metadata:

6.3. Custom decorators

Used to create reusable, declarative APIs instead of repeating low-level logic.

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

Then:

@Roles('admin')
@Get()
findAll() {}

Then read operation

const roles = this.reflector.get(Roles, context.getHandler());

6.4. Parameter decorators

Decorators applied to controller method parameters to extract values from the request.

@Get(':id')
findOne(
  @Param('id') id: string,
  @Query('limit') limit: number,
  @User() user: User,
) {}

Nest provides built-in parameter decorators such as @Body(), @Param(), @Query(), @Headers(), and @Req().

You can create your own with createParamDecorator():

export const User = createParamDecorator((_data, ctx: ExecutionContext) => {
  const request = ctx.switchToHttp().getRequest();
  return request.user;
});

6.5. ExecutionContext

ExecutionContext provides information about the current execution context and allows code to work across different transports.

context.getHandler(); // current method context.getClass(); // current controller

You can switch to a specific transport:

context.switchToHttp().getRequest(); context.switchToRpc().getContext(); context.switchToWs().getClient();

This is why guards/interceptors can be written in a transport-aware way.

7. Validation

NestJS typically uses DTOs together with ValidationPipe to validate and transform incoming data at the application boundary.

7.1. DTOs

DTOs define the expected shape of incoming data and, with class-validator, can contain validation rules.

class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;
}

7.2. ValidationPipe

ValidationPipe automatically validates DTOs before the handler executes.

app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

Common options:

7.3. Transformation

With transform: true, ValidationPipe can transform incoming values into the expected DTO/types.

new ValidationPipe({ transform: true });

It can also transform primitive route/query parameters when appropriate, e.g. “42” → 42.

7.4. Whitelisting

whitelist: true removes properties that don’t have validation decorators from the DTO.

{
  email: "a@b.com",
  isAdmin: true
}

If isAdmin isn’t defined/validated in the DTO, it gets removed.

forbidNonWhitelisted: true instead throws an error when such properties are sent.

7.5. Custom pipes

Use custom pipes when built-in pipes aren’t enough - typically for custom transformation or validation of a single argument.

@Injectable()
export class ParseUuidPipe implements PipeTransform {
  transform(value: string) {
    if (!isValidUuid(value)) {
      throw new BadRequestException("Invalid UUID");
    }

    return value;
  }
}

A useful distinction: ValidationPipe is usually for DTO-level validation, while custom pipes are useful for argument-specific transformation/validation.

8. Exception Handling

NestJS provides built-in exceptions and exception filters for handling errors at the HTTP layer.

8.1. HttpException

Base class for HTTP exceptions. You can specify the status code and response body.

throw new HttpException("Forbidden", HttpStatus.FORBIDDEN);

8.2. Built-in exceptions

Nest provides common exceptions such as:

They automatically produce appropriate HTTP responses.

8.3. Exception filters

Filters customize how exceptions are handled and what response is returned.

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    // customize response
  }
}

Can be applied at method, controller, or global level.

Custom exceptions can simply extend HttpException when the application needs domain-specific error types.

export class UserNotFoundException extends NotFoundException {}

9. Configuration

NestJS provides @nestjs/config for managing environment variables and application configuration.

9.1. ConfigModule

ConfigModule.forRoot() loads environment variables and makes ConfigService available for reading configuration.

ConfigModule.forRoot({
  isGlobal: true,
});

# then
constructor(private readonly config: ConfigService) {}

const port = this.config.get<number>('PORT');

9.2. Environment variables & validation

Environment variables can be validated on startup to fail fast when required configuration is missing or invalid.

Commonly done with Joi or another validation library.

ConfigModule.forRoot({
  validationSchema: Joi.object({
    DATABASE_URL: Joi.string().required(),
  }),
});

9.3. Configuration factories & namespacing

Factories allow configuration to be defined programmatically and can be combined with namespaces to keep related settings grouped.

registerAs("database", () => ({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
}));

Then:

config.get("database.host");

9.4. Runtime configuration

Configuration is loaded during application startup and is not automatically reloaded when .env changes.

Runtime changes are possible when configuration is explicitly stored and managed through a dynamic source such as a database, Redis, or an external configuration service.

configService.set("someKey", "newValue");

10. Application Lifecycle

NestJS provides lifecycle hooks for running logic when modules initialize, the application starts, and the application shuts down.

10.1. OnModuleInit

Called once a module’s dependencies have been resolved and the module has been initialized.

Useful for initialization logic that depends on injected providers.

onModuleInit() {
  // initialization logic
}

10.2. OnModuleDestroy

Called when NestJS is shutting down and the module is being destroyed. Useful for cleaning up resources such as connections, workers, or subscriptions.

10.3. OnApplicationBootstrap

Called once all modules have been initialized and the application is ready to bootstrap. Useful when initialization depends on the entire application being ready.

10.4. OnApplicationShutdown

Called when the application is shutting down, after a shutdown signal is received. Useful for final cleanup logic.

10.5. Graceful shutdown

Allows the application to finish handling existing requests and clean up resources before terminating.

Enable shutdown hooks:

app.enableShutdownHooks();

This allows Nest to react to OS signals such as SIGTERM and SIGINT and execute the appropriate shutdown lifecycle hooks.


11. Authentication and Authorization

Authentication answers who the user is, while authorization answers what the user is allowed to do.

In NestJS, authentication is commonly handled with Passport and a strategy such as JWT, while Guards are used to control whether a request can access a route.

Typical authentication and authorization flow

11.1. Guards

Guards determine whether a request is allowed to reach the route handler. They implement canActivate and can inspect the request before deciding whether to allow or reject it.

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    return !!request.user;
  }
}

Guards are commonly used for:

They can be applied globally, to a controller, or to a specific route:

@UseGuards(AuthGuard)
@Get()
findAll() {}

11.2. Passport

Passport package provides a strategy-based authentication system. A strategy defines how credentials are verified and how the authenticated user is obtained.

It is especially useful when an application supports multiple authentication methods, such as:

In NestJS, Passport strategies are usually combined with guards:

Authentication flow with Passport

Simple web apps: For a Next.js frontend and a NestJS API, session-based authentication with an HTTP-only cookie is often the simplest choice. It avoids Passport and JWT, resulting in a more straightforward flow and fewer dependencies to maintain.

11.3. JWT

JWT (JSON Web Token) is a token format commonly used for stateless authentication.

After successful authentication, the server signs a token containing claims such as the user’s ID:

{
  sub: user.id
}

The client sends the token with subsequent requests:

Authorization: Bearer <token>

The receiving service verifies the token’s signature and can use its claims to identify the user without maintaining a server-side session.

JWTs are particularly useful in more distributed architectures, where multiple services need to independently verify a user’s identity without calling a central authentication service for every request.

JWT authentication can be implemented directly in NestJS without Passport. Passport provides a convenient strategy-based abstraction, but if JWT is the only authentication mechanism, using it may add unnecessary complexity and dependencies.

11.4. Roles

Roles provide simple authorization based on a user’s role.

@Roles('admin')
@UseGuards(AuthGuard, RolesGuard)
@Delete(':id')
remove() {}

A RolesGuard can read the required roles from route metadata and compare them with the authenticated user’s roles.

11.5. Permissions

Permissions provide more fine-grained authorization than roles.

Instead of checking:

user.role === "admin";

you can check capabilities such as:

users:read
users:write
posts:delete

A user can receive permissions directly or inherit them through roles.

posts:read
posts:write
posts:delete

User roles and permissions

Roles are useful for general access control, while permissions are better when access rules become more detailed.

11.6. Custom authorization decorators

Custom decorators can attach authorization metadata to routes, which can then be read by a guard.

export const Roles = (...roles: string[]) => SetMetadata("roles", roles);

Usage:

@Roles('admin', 'moderator')
@Get()
findAll() {}

The decorator itself does not perform authorization. It only declares metadata; a guard is responsible for reading that metadata and enforcing the rule.

11.7. RBAC

Role-Based Access Control (RBAC) grants permissions based on a user’s role.

RBAC permission model

For example:

admin → users:read, users:write, users:delete editor → posts:read, posts:write viewer → posts:read

In NestJS, RBAC is commonly implemented with custom decorators that define required roles and a guard that checks them against the authenticated user.

RBAC is useful when access rules can be expressed primarily through roles. For more complex rules, such as “users can edit only their own posts”, additional authorization logic or a permissions/policy-based approach may be needed.


12. AsyncLocalStorage

AsyncLocalStorage is a Node.js mechanism for storing context that is automatically available throughout the lifetime of an asynchronous execution chain.

It is useful for keeping request-specific context such as:

without passing these values explicitly through every function call.

const storage = new AsyncLocalStorage<{ requestId: string }>();

storage.run({ requestId: "123" }, () => {
  // Any async code executed here can access the context
  service.doSomething();
});

const { requestId } = storage.getStore()!;

Unlike NestJS request-scoped providers, AsyncLocalStorage does not create new provider instances. It only propagates contextual data through asynchronous operations.


13. NestJS and HTTP

NestJS provides an abstraction over HTTP servers while keeping most application code independent of the underlying platform.

13.1. Express adapter

Default HTTP adapter in NestJS.

NestFactory.create(AppModule);

Pros:

13.2. Fastify adapter

Alternative HTTP platform focused on performance.

NestFactory.create(AppModule, new FastifyAdapter());

Pros:

13.3. Summary


14. Queues and background jobs

For background jobs in NestJS, BullMQ is a common choice. It is a Redis- backed job queue used for asynchronous/background work such as sending emails, generating reports, processing files, scheduled jobs, retries, etc.

BullMQ is not a replacement for RabbitMQ or Kafka - it solves a different problem and is closer to Oban from the Elixir/Phoenix ecosystem. NestJS provides integration with BullMQ through @nestjs/bullmq.


15. Events

NestJS provides @nestjs/event-emitter for simple in-process event-driven communication within a single application.

15.1. EventEmitter

Events can be emitted without directly coupling the producer to its consumers.

this.eventEmitter.emit("user.created", { userId });

15.2. Event handlers

Handlers subscribe to events using @OnEvent().

@OnEvent('user.created')
handleUserCreated(payload: { userId: string }) {
  // ...
}

16. Microservices

NestJS provides the @nestjs/microservices module for building services that communicate with each other using different transport mechanisms such as TCP, Redis, NATS, RabbitMQ, Kafka and gRPC.

The main benefit is a common NestJS programming model for inter-service communication (ClientProxy, @MessagePattern(), @EventPattern(), etc.), while the underlying transport can differ.

This is a NestJS abstraction over microservice communication - it does not define the microservice architecture itself.


17. Performance

17.1. Request-scoped providers and performance

Request-scoped providers create a new instance for each request instead of reusing a singleton.

This can increase memory usage and CPU overhead, especially when the dependency tree becomes request-scoped.

17.2. Blocking the event loop

Node.js uses a single main event loop, so CPU-intensive or synchronous operations can block other requests.

Avoid expensive synchronous operations in request handlers. Move CPU-heavy work to workers/background jobs when necessary.

17.3. Fastify vs. Express

Fastify generally has lower overhead and higher throughput than Express, although the real difference depends on the application and workload.

17.4. Workers and clustering

For CPU-intensive work, Node.js provides Worker Threads, which run JavaScript in separate threads without blocking the main event loop.

Cluster allows running multiple Node.js processes, typically using multiple CPU cores. In production, horizontal scaling with multiple application instances behind a load balancer is often preferred.

17.5. Application profiling

When performance matters, measure before optimizing.

Use CPU/heap profiling, APM, load testing and database/query profiling to identify actual bottlenecks.