
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.
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.
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.
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:
Handles other requests while PostgreSQL works
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.
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) {}
}
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.
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) {}
}
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.
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.
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.
Core mechanism for organizing the application and defining dependency boundaries. Modules are not just folders - they control the dependency graph and encapsulation.
Providers are private to the module by default. Use exports to expose them.
Think of exports as the module’s public API.
imports - modules whose exported providers this module needs.exports - providers/modules exposed to modules that import this module.@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@Global() makes a module available across the application without explicitly importing it everywhere.
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
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],
});
ModuleRef gives programmatic access to Nest’s DI container.
constructor(private readonly moduleRef: ModuleRef)
const service = this.moduleRef.get(MyService);
Useful for:
Serialization is the process of converting objects into a format suitable for sending over the network, usually JSON.
Uses class-transformer to transform class instances before returning them from controllers.
@UseInterceptors(ClassSerializerInterceptor)
@Controller("users")
export class UsersController {}
Common decorators: @Exclude(), @Expose(), @Transform().
Allow different representations of the same class depending on context.
@Expose({ groups: ['admin'] })
internalId: string;
@SerializeOptions({ groups: ['admin'] })
Useful for public vs admin responses.
Use @Exclude() for sensitive or internal fields:
@Exclude()
passwordHash: string;
Prefer dedicated response DTOs over exposing ORM entities directly.
Use @Transform() to change the serialized representation:
@Transform(({ value }) => value.toISOString())
createdAt: Date;
@Exclude, @Expose, @Transform, groupsNest processes a request through several layers. The key is knowing what runs when and what each layer is responsible for.
For a typical HTTP request:
A useful mental model:
before
after
Typical NestJS HTTP request lifecycle
Runs before the route handler pipeline.
Good for generic request processing:
reqDoesn’t have access to Nest’s ExecutionContext or route-specific metadata.
Run after middleware, before interceptors/pipes/handler.
Main purpose: authorization / deciding whether the request can proceed.
canActivate(context: ExecutionContext): boolean
Wrap the handler execution. They can run before and after the handler.
Useful for:
return next.handle().pipe(map((data) => transform(data)));
Run before the handler receives the arguments.
Main responsibilities:
"123" -> 123 (string to number)@Get()
find(@Param('id', ParseIntPipe) id: number) {}
Handle exceptions that weren’t already handled.
They can customize:
@Catch(HttpException)
export class HttpExceptionFilter {}
NestJS uses metadata + decorators heavily to attach information to classes, methods, and parameters, and then read that information at runtime.
Metadata is information attached to a class/method/property using decorators.
@SetMetadata('roles', ['admin'])
@Get()
findAll() {}
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:
get() - reads metadata from a single target.getAllAndOverride() - checks multiple targets and returns the first defined value.getAllAndMerge() - reads metadata from multiple targets and merges the values.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());
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;
});
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.
NestJS typically uses DTOs together with ValidationPipe to validate and transform incoming data at the application boundary.
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;
}
ValidationPipe automatically validates DTOs before the handler executes.
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
Common options:
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.
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.
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.
NestJS provides built-in exceptions and exception filters for handling errors at the HTTP layer.
Base class for HTTP exceptions. You can specify the status code and response body.
throw new HttpException("Forbidden", HttpStatus.FORBIDDEN);
Nest provides common exceptions such as:
They automatically produce appropriate HTTP responses.
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 {}
NestJS provides @nestjs/config for managing environment variables and application configuration.
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');
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(),
}),
});
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");
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");
NestJS provides lifecycle hooks for running logic when modules initialize, the application starts, and the application shuts down.
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
}
Called when NestJS is shutting down and the module is being destroyed. Useful for cleaning up resources such as connections, workers, or subscriptions.
Called once all modules have been initialized and the application is ready to bootstrap. Useful when initialization depends on the entire application being ready.
Called when the application is shutting down, after a shutdown signal is received. Useful for final cleanup logic.
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.
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
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() {}
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.
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.
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.
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.
User roles and permissions
Roles are useful for general access control, while permissions are better when access rules become more detailed.
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.
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.
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:
requestIduserIdtenantIdwithout 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.
NestJS provides an abstraction over HTTP servers while keeping most application code independent of the underlying platform.
Default HTTP adapter in NestJS.
NestFactory.create(AppModule);
Pros:
Alternative HTTP platform focused on performance.
NestFactory.create(AppModule, new FastifyAdapter());
Pros:
NestJS is platform-agnostic, Express is the defaultNestJS architecture@Res() unless you actually need low-level response controlFor 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.
NestJS provides @nestjs/event-emitter for simple in-process event-driven communication within a single application.
Events can be emitted without directly coupling the producer to its consumers.
this.eventEmitter.emit("user.created", { userId });
Handlers subscribe to events using @OnEvent().
@OnEvent('user.created')
handleUserCreated(payload: { userId: string }) {
// ...
}
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.
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.
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.
Fastify generally has lower overhead and higher throughput than Express, although the real difference depends on the application and workload.
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.
When performance matters, measure before optimizing.
Use CPU/heap profiling, APM, load testing and database/query profiling to identify actual bottlenecks.