Introduction
NestJS has quickly become the go-to framework for developers who want to build scalable, modular, and maintainable Node.js applications. Leveraging TypeScript out of the box, NestJS introduces a powerful set of architectural patterns such as dependency injection, modules, and interceptors that align well with enterprise-level development. In this comprehensive guide we will dive deep into everything you need to know to get started with NestJS, from core concepts and architecture to step-by-step implementation, real-world examples, production-grade code, performance tips, security considerations, and deployment strategies.
By the end of this article you will have a solid understanding of how to design a well-structured NestJS project, apply best practices, avoid common pitfalls, and write clean, testable code. Whether you are a junior developer looking to level up or a senior engineer seeking to standardize a Node.js backend, this guide is crafted to be both practical and thorough, offering actionable examples you can copy and adapt immediately.
Table of Contents
- Introduction
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
Before we write any code, we need to understand the philosophical underpinnings of NestJS. The framework is heavily inspired by Angular and applies several design patterns that promote separation of concerns and testability.
Dependency Injection
Dependency Injection (DI) is at the heart of NestJS. Services, providers, and other components can declare their dependencies, and Nest will automatically resolve and inject them. This makes testing trivial because you can mock dependencies without navigating complex constructor calls.
Modules
Modules are the building blocks of a Nest application. A module groups related features, providers, and controllers. By using the @Module decorator you define an entry point for a logical boundary. Nest will internally maintain a graph of module relationships, which helps with tree‑based injection and CLI tooling.
Controllers and Services
Controllers handle incoming HTTP requests and return responses. They should be thin and focus on request/response mapping. Services contain business logic and can be injected into controllers, other services, or even other providers. Keeping controllers thin ensures that the application's core logic stays independent of HTTP specifics.
Providers
Providers are responsible for creating and exposing values to the DI system. They can be classes, factories, or values. NestJS uses the TypeScript @Injectable() decorator to mark a class as a provider. Each provider is scoped to the application, request, or session depending on its scope setting.
Interceptors, Pipes, and Guards
Intercepting request/response flow, transforming data, and enforcing access control are all expressed through decorators: Interceptor, Pipe, and Guard. These features enable reusable cross‑cutting concerns such as logging, validation, authentication, and error handling.
Exception Filters
When an exception is thrown inside a controller, NestJS can run a filter to customize how it is handled. This is essential for returning consistent error responses and for logging errors before they bubble up.
Reflection and Metadata
Nest leverages TypeScript reflection to inspect parameter types, decorators, and class metadata. This allows the framework to automatically map route parameters, query strings, and request bodies using built‑in pipes.
CLI
The official Nest CLI provides generators for modules, controllers, services, and other entities. It also supports building, testing, and packaging the application with TypeScript transpilation and dependency management.
Understanding these core concepts is the first step toward writing maintainable NestJS code. In the next section we will explore how these pieces fit together in an architectural overview.
Architecture Overview
The NestJS architecture can be visualized as a layered system that mirrors a typical MVC (Model‑View‑Controller) pattern but adds additional abstractions for cross‑cutting concerns.
Layered View
- Presentation Layer (Controllers): Handles HTTP requests, websockets, or gRPC messages. Controllers are entry points that define routes using standard Express.js methods.
- Domain Layer (Services & Providers): Contains the core business logic. Services are injected into controllers and may also use other services for orchestration.
- Data Access Layer (Repositories, TypeORM/Mongoose, etc.): Interacts with the database. Repositories abstract database calls and are injected into services.
- Cross‑cutting Layers: Interceptors modify request/response, Pipes transform and validate input, Guards enforce policies, and Filters handle exceptions.
Modules act as a container for each layer, enabling logical separation and reducing circular dependencies. For example, a UsersModule may contain a UsersController, a UserService, a UserRepository, and several providers for validation, logging, or caching.
Dependency Injection Tree
Because NestJS is based on a hierarchical injector, the same provider can be injected at different levels with different lifetimes. Scope can be set using the ScopedProvider or Inject decorators. The default application‑wide scope is perfect for stateless services, while request‑scoped providers are useful for services that need per‑request context (e.g., request‑id, locale).
Routing and Middleware
Routing is defined with decorators (@Get, @Post, etc.) and uses Express.js under the hood, but Nest provides a unified way to declaratively define routes across modules. Middleware can be registered globally, per module, or per controller, allowing custom logic like CORS, body parsing, or third‑party integrations.
Error Handling and Logging
Exception filters are the preferred way to standardize error output. Nest ships with a default HTTP exception filter that produces JSON with status codes and error messages. For logging, Nest supports Pino, Winston, and other loggers via the Logger service.
Testing Philosophy
Thanks to dependency injection and modular design, unit tests can mock providers easily. Nest provides a testing module (TestingModule) and utilities like INestApplication for integration testing HTTP endpoints without running a server.
In practice, you will start by defining modules, then create controllers and services, then add pipes for validation, guards for authentication, and filters for error handling. The architecture naturally leads to code that is testable, maintainable, and scalable.
Step-by-Step Guide
Now we move from theory to practice. This section walks through the creation of a simple but realistic NestJS project from scratch using the official CLI. We will build an API for managing a list of products.
1. Install the CLI and Create a New Project
npm i -g @nestjs/cli
nest new product-api --plain
The --plain flag skips TypeScript and component plugins, giving a clean TypeScript project. After installation, change into the directory and run npm run start:dev to see the default Hello World application.
2. Define a Module Structure
In a Nest application, each logical feature lives in a separate module. For the product API we will create a ProductsModule.
// src/products/products.module.ts
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}
The module is then imported into the root AppModule (or any parent module). This is how Nest composes the dependency graph.
3. Create the Controller with Routes
Controllers define endpoints. We will implement CRUD actions: list, create, read, update, delete.
// src/products/products.controller.ts
import { Controller, Get, Post, Body, Param, Patch, Delete } from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll() {
return this.productsService.findAll();
}
@Post()
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.update(id, updateProductDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
Note the use of DTOs (Data Transfer Objects). DTOs keep the request payload strictly defined and are automatically validated using Pipes.
4. Build the Service and Domain Logic
The service holds the business logic and interacts with a repository. For simplicity, we will use an in-memory array. In a real app you would inject a repository that talks to a database.
// src/products/products.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private readonly productsRepository: Repository,
) {}
async findAll() {
return this.productsRepository.find();
}
async create(createProductDto: CreateProductDto) {
const product = this.productsRepository.create(createProductDto);
return this.productsRepository.save(product);
}
async findOne(id: string) {
return this.productsRepository.findOneBy({ id });
}
async update(id: string, updateProductDto: UpdateProductDto) {
await this.productsRepository.update(id, updateProductDto);
return this.findOne(id);
}
async remove(id: string) {
await this.productsRepository.delete(id);
return { deleted: true };
}
}
Here we used @InjectRepository to inject a TypeORM repository. If you prefer a simpler approach, you can just use an array and eliminate the ORM dependency.
5. Define the Product Entity
For TypeORM we need an entity class that maps to a database table.
// src/products/product.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { IsString, IsNumber } from 'class-validator';
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
@IsString()
name: string;
@Column('decimal')
@IsNumber()
price: number;
}
Class validators (via class-validator) can be used in combination with the ValidationPipe to enforce constraints.
6. Set Up DTOs and Validation
DTOs are plain classes used to transfer data between client and server. They are often combined with class-validator for validation.
// src/products/dto/create-product.dto.ts
import { IsString, IsNumber } from 'class-validator';
export class CreateProductDto {
@IsString()
name: string;
@IsNumber()
price: number;
}
// src/products/dto/update-product.dto.ts
import { IsOptional } from 'class-validator';
export class UpdateProductDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsNumber()
price?: number;
}
In the controller we can enable validation globally via ValidationPipe.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
7. Add Interceptors and Guards (Optional but Recommended)
Interceptors can log every request. Here is a simple logging interceptor.
// src/logging.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable {
const request = context.switchToHttp().getRequest();
console.log(`[${request.method}] ${request.url} - ${new Date().toISOString()}`);
return next.handle().pipe(tap(() => console.log('Request completed')));
}
}
Apply globally:
app.useGlobalInterceptors(new LoggingInterceptor());
8. Create a Guard for Authentication (Example)
This guard checks for a simple API key header. Real applications will use JWT or OAuth2.
// src/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise | Observable {
const request = context.switchToHttp().getRequest();
const apiKey = request.headers['x-api-key'];
// For demonstration, compare with a constant.
return apiKey === 'supersecret';
}
}
Apply the guard to routes:
// src/products/products.controller.ts
import { AuthGuard } from '../auth.guard';
@Controller('products')
@UseGuards(AuthGuard)
export class ProductsController { ... }
9. Start the Application
Now you can run the application using
npm run start:dev
Open http://localhost:3000/products to see an empty list (since no data). Use Postman or curl to test the endpoints with a JSON body:
curl -X POST http://localhost:3000/products -H "Content-Type: application/json" -d '{"name":"Laptop","price":999.99}'
Return a JSON representation of the created product.
10. Running Tests
NestJS comes with a test runner that uses Jest. We can write a test for the service using the TestingModule.
// src/products/products.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsService } from './products.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Product } from './product.entity';
describe('ProductsService', () => {
let service: ProductsService;
let repository: Repository;
beforeEach(async () =>n {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductsService,
{
provide: getRepositoryToken(Product),
useClass: mockRepository,
},
],
}).compile();
service = module.get(ProductsService);
repository = module.get(getRepositoryToken(Product));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
With mocks you can assert that the service calls the repository correctly.
Real-World Examples
Applying NestJS to a real scenario helps solidify understanding. Below are three typical patterns you will encounter in production.
Example 1: E-commerce Product Catalog
An e-commerce platform needs a product catalog API that supports CRUD, filtering, pagination, and search. Using NestJS you can organize the following modules:
- ProductsModule: contains all product-related controllers, services, and repositories.
- CategoriesModule: manages product categories with nested routing.
- ReviewsModule: handles user reviews using event-driven architecture (NATS or Redis).
Each module can have its own validation pipes (price must be > 0, slug uniqueness), guards (admin roles), and interceptors (caching responses). By leveraging the CLI, you can scaffold these modules with a single command and maintain a clean folder structure.
Example 2: SaaS Multi‑Tenant API
In a SaaS product, you need to isolate data per tenant. NestJS supports tenant‑aware providers using request‑scoped injection. A typical implementation includes:
- TenantGuard: validates the tenant identifier in the request header and attaches a tenant context.
- TenantService: returns the appropriate database connection (using TypeORM's multiple data sources).
- TenantModule: registers the guard globally and provides the service.
By using modules, you can load different database configurations per tenant without mixing concerns.
Example 3: Real‑Time Chat Application
NestJS supports WebSockets via the @nestjs/platform-ws package. A chat app would contain:
- ChatGateway: decorated with
@WebSocketGatewayand@SubscribeMessageto handle events. - ChatService: persists messages using a repository.
- MessagePublisher: publishes events to a message queue (e.g., Redis) for real‑time updates to WebSocket clients.
Because NestJS provides a clean separation between WebSocket controllers and HTTP controllers, you can evolve a simple RPC service into a full real‑time platform without rewriting code.
Production Code Examples
Below is a concise, production‑ready code snippet that showcases best practices: module organization, dependency injection with TypeORM, validation, authentication via JWT, and error handling with custom exception filters.
1. Project Structure
src/
├── config/
│ ├── database.config.ts
│ └── env.config.ts
├── core/
│ ├── exceptions/
│ │ ├── http-exception.filter.ts
│ │ └── rpc-exception.filter.ts
│ └── guards/
│ └── jwt-auth.guard.ts
├── modules/
│ ├── users/
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── users.repository.ts
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ └── auth.service.ts
│ └── shared/
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ └── constants.ts
├── app.module.ts
├── main.ts
└── app.service.ts
2. Database Configuration
// src/config/database.config.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 5432,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/../**/*.entity.{ts,js}'],
synchronize: process.env.NODE_ENV !== 'production', // Never true in production!
logging: process.env.NODE_ENV === 'development',
ssl: process.env.NODE_ENV === 'production',
};
3. Global Validation and Auth Guard
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_PIPE, APP_GUARD } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { JwtAuthGuard } from './core/guards/jwt-auth.guard';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({
imports: [UsersModule, AuthModule],
providers: [
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
4. JWT Authentication Guard
// src/core/guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) return false;
const token = authHeader.split(' ')[1];
try {
const payload = this.jwtService.verify(token, { secret: process.env.JWT_SECRET });
request.user = payload;
return true;
} catch (e) {
return false;
}
}
}
5. Custom HTTP Exception Filter
// src/core/exceptions/http-exception.filter.ts
import { Catch, ExceptionFilter, HttpException, ArgumentsHost, Logger } from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception.getStatus();
const errorResponse = exception.getResponse();
// Log the error for debugging
this.logger.error(`HTTP Exception: ${exception.message}`, exception.stack);
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
error: typeof errorResponse === 'string' ? errorResponse : (errorResponse as any).message,
});
}
}
6. Auth Module with JWT
// src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '15m' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
7. Auth Controller and Service
// src/modules/auth/auth.controller.ts
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
}
// src/modules/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async login(loginDto: LoginDto) {
const user = await this.usersService.validateUser(loginDto.email, loginDto.password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwtService.sign(payload),
};
}
}
8. Users Module and Repository
// src/modules/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
// src/modules/users/users.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User)
private readonly repo: Repository,
) {}
async findByEmail(email: string): Promise {
return this.repo.findOne({ where: { email } });
}
async create(userData: Partial): Promise {
const user = this.repo.create(userData);
return this.repo.save(user);
}
}
9. Users Controller
// src/modules/users/users.controller.ts
import { Controller, Get, Post, Body, Param, Delete, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { JwtAuthGuard } from '../../core/guards/jwt-auth.guard';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(id);
}
}
10. Users Service (Business Logic)
// src/modules/users/users.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { CreateUserDto } from './dto/create-user.dto';
@Injectable()
export class UsersService {
constructor(private repo: UsersRepository) {}
async findAll() {
return this.repo.findByEmail(''); // Returns all users (implementation may differ)
}
async create(createUserDto: CreateUserDto) {
const existing = await this.repo.findByEmail(createUserDto.email);
if (existing) {
throw new ConflictException('Email already registered');
}
return this.repo.create(createUserDto);
}
async remove(id: string) {
// repository method for deletion omitted for brevity
return { deleted: true };
}
async validateUser(email: string, password: string) {
// Verify password against hash; pseudocode
const user = await this.repo.findByEmail(email);
if (user && user.password === hash(password)) {
// strip password before returning
delete user.password;
return user;
}
return null;
}
}
Comparison Table
| Aspect | NestJS | Express.js (plain) | Fastify |
|---|---|---|---|
| Built‑in Dependency Injection | Yes (core) | No (needs extra libraries) | Limited (needs extra) |
| TypeScript Support | First‑class | None (but available via TS‑ify) | Good, but not native |
| Scalable Architecture Patterns | Modules, interceptors, guards | decent but fewer abstractions | |
| Performance | Good for most workloads, overhead minimal | Fastest raw performance | Very fast, optimized for speed |
| Out‑of‑the‑box Validation | ValidationPipe + class‑validator | Built‑in validation via schemas | |
| Community & Ecosystem | Strong, official CLI, plugins | Massive (third‑party) | Growing, solid core |
Best Practices
- Keep Modules Small and Cohesive. Each module should have a clear, single responsibility. This reduces cyclic dependencies and makes testing easier.
- Use Providers with Proper Scope. Application‑wide providers should be stateless; request‑scoped providers are ideal for data like request id or user context.
- Separate Interfaces from Implementations. Use repository interfaces and abstract services to keep the code flexible.
- Apply Pipes for Input Validation. Global validation helps avoid many security issues and ensures consistent request shape.
- Leverage Interceptors for Cross‑Cutting Concerns. Logging, timing, caching, and transformation can all be added as reusable interceptors.
- Implement Guards to Protect Resources. Authentication, authorization, and rate limiting are enforced with guards.
- Structure Providers with Dependency Injection. Use constructor injection for services, and parameterize dependencies wherever possible.
- Write Unit Tests for Providers and Controllers. Nest's testing utilities make mocking providers straightforward.
- Follow SEMVER for API Versioning. Use route prefixes like
/v1/usersto version endpoints. - Employ Environment‑Based Configuration. Separate configuration files for development, test, and production to avoid accidental leaks.
Common Mistakes
- Circular Dependencies. Importing modules inside providers can cause loops. Use forward references or refactor into separate modules.
- Over‑Injecting. Injecting many low‑value services can make the DI container heavy and obscure real dependencies.
- Neglecting Input Validation. Trusting client‑provided data leads to bugs and security vulnerabilities.
- Mixing HTTP and WebSocket Modules. While Nest supports both, mixing them in the same module can cause noisy code. Keep them separate.
- Hard‑coding Secrets. Storing passwords, JWT secrets, or DB credentials in source code leads to exposure.
- Ignoring Error Boundaries.