
Introduction π―
NestJS is a progressive Node.js framework for building efficient, scalable server-side applications. It uses TypeScript by default, provides built-in dependency injection, and enforces architectural patterns that scale from tiny APIs to enterprise systems.
This guide follows NestJSβs official documentation structureβOVERVIEW through FUNDAMENTALSβbut with practical, hands-on examples. Weβll build real things, understand why each decision matters, and equip you to architect production applications.
By the end, youβll understand how NestJS enforces good architecture and prevents common backend mistakes. Letβs build. π
Part 1: OVERVIEWβWhy NestJS Matters
1.1 What Makes NestJS Different?
Express vs Fastify vs NestJS:
(#) Express Fastify NestJS
Architecture π¦ Minimal π¦ Minimal ποΈ Opinionated
Patterns No pattern No pattern β
Built-in
DI Container β Manual β Manual β
Automatic
Testing π Hard π Hard β
Easy
Scale π With work π With work β
Automatic
NestJS gives you:
- Architecture out of the box: Modules, Controllers, Services, Providersβno decisions needed
- Dependency Injection: Never pass dependencies manually; the framework handles it
- Testability: Mock providers easily; tests are simple
- Type Safety: Full TypeScript support (not optional)
- Scalability: Works the same way from 100 requests/sec to 100K
1.2 When to Use NestJS
β Use NestJS when:
- Building production APIs that will grow
- Team size > 1 person (consistency matters)
- You want TypeScript without fighting the framework
- You need dependency injection
- Building microservices
β Skip NestJS if:
- Building a simple CRUD API in 30 minutes (use Express)
- You hate boilerplate (thereβs a little)
- Youβre learning Node.js basics (start with Express first)
- Building real-time with WebSockets only (possible but extra work)
1.3 NestJS Architecture Overview
βββββββββββββββββββββββββββββββββββββββββββββββ
β HTTP Request β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Middleware (cors, logging, etc) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Guards (authentication, authorization) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Interceptors (transform, cache, timing) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Pipes (validation, transformation) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Controller (route to service) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Service (business logic) β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β Response β
βββββββββββββββββββββββββββββββββββββββββββββββ
Part 2: FUNDAMENTALSβBuilding Your First NestJS App
2.1 Installation and Project Setup
# Install NestJS CLI (one-time)
npm i -g @nestjs/cli
# Create new project
nest new my-app
cd my-app
# Project structure
my-app/
βββ src/
β βββ app.controller.ts # Main controller
β βββ app.service.ts # Main service
β βββ app.module.ts # Root module
β βββ main.ts # Entry point
βββ test/ # End-to-end tests
βββ package.json
βββ tsconfig.json
# Run development server
npm run start:dev
# Server runs on http://localhost:3000
2.2 ModulesβOrganizing Your Code
A module bundles related controllers, services, and other providers:
// users/users.module.ts
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // Available to other modules
})
export class UsersModule {}
// app.module.ts (root module)
import { Module } from "@nestjs/common";
import { UsersModule } from "./users/users.module";
import { PostsModule } from "./posts/posts.module";
@Module({
imports: [UsersModule, PostsModule], // Import submodules
controllers: [],
providers: [],
})
export class AppModule {}
2.3 ControllersβHandle Requests
Controllers receive requests and delegate to services:
// users/users.controller.ts
import { Controller, Get, Post, Body, Param } from "@nestjs/common";
import { UsersService } from "./users.service";
interface CreateUserDTO {
email: string;
name: string;
password: string;
}
interface UserResponse {
id: number;
email: string;
name: string;
}
@Controller("users") // Route prefix: /users
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// π GET /users
@Get()
async getAllUsers(): Promise<UserResponse[]> {
return this.usersService.findAll();
}
// π GET /users/:id
@Get(":id")
async getUserById(@Param("id") id: string): Promise<UserResponse> {
return this.usersService.findById(parseInt(id));
}
// π POST /users
@Post()
async createUser(@Body() createUserDTO: CreateUserDTO): Promise<UserResponse> {
return this.usersService.create(createUserDTO);
}
}
Key decorators:
@Get(),@Post(),@Patch(),@Delete()β HTTP methods@Param()β Route parameters (:id)@Query()β Query string (?page=1)@Body()β Request body@Headers()β HTTP headers
2.4 ServicesβBusiness Logic
Services contain reusable logic. Controllers call services:
// users/users.service.ts
import { Injectable } from "@nestjs/common";
interface User {
id: number;
email: string;
name: string;
passwordHash: string;
}
@Injectable() // Can be injected into other classes
export class UsersService {
private users: User[] = []; // Mock database
private idCounter = 1;
async findAll(): Promise<Omit<User, "passwordHash">[]> {
return this.users.map(({ passwordHash, ...user }) => user);
}
async findById(id: number): Promise<Omit<User, "passwordHash"> | null> {
const user = this.users.find((u) => u.id === id);
if (!user) return null;
const { passwordHash, ...safe } = user;
return safe;
}
async create(data: {
email: string;
name: string;
password: string;
}): Promise<Omit<User, "passwordHash">> {
const user: User = {
id: this.idCounter++,
email: data.email,
name: data.name,
passwordHash: hashPassword(data.password), // Never store plain text!
};
this.users.push(user);
const { passwordHash, ...safe } = user;
return safe;
}
}
function hashPassword(password: string): string {
// Use bcrypt in real app
return Buffer.from(password).toString("base64");
}
2.5 Dependency InjectionβThe Heart of NestJS
NestJS automatically provides dependencies:
// posts/posts.service.ts
import { Injectable } from "@nestjs/common";
import { UsersService } from "../users/users.service";
@Injectable()
export class PostsService {
constructor(private readonly usersService: UsersService) {}
// UsersService is automatically injected!
async createPost(userId: number, title: string, content: string) {
const user = await this.usersService.findById(userId);
if (!user) throw new Error("User not found");
return {
id: 1,
userId,
title,
content,
createdAt: new Date(),
};
}
}
Why DI matters:
- β Tests: Easy to mock dependencies
- β Loose coupling: Services donβt create each other
- β Configuration: Swap implementations without code changes
Part 3: Advanced Fundamentals
3.1 ProvidersβBeyond Services
A provider is anything NestJS can inject:
// database.provider.ts
import { Provider } from "@nestjs/common";
export const databaseProvider: Provider = {
provide: "DATABASE_CONNECTION", // Unique token
useValue: createDatabaseConnection(), // Provide the instance
};
function createDatabaseConnection() {
return {
query: async (sql: string) => {
/* ... */
},
};
}
// database.module.ts
import { Module } from "@nestjs/common";
@Module({
providers: [databaseProvider],
exports: [databaseProvider],
})
export class DatabaseModule {}
// users/users.service.ts
import { Inject, Injectable } from "@nestjs/common";
@Injectable()
export class UsersService {
constructor(@Inject("DATABASE_CONNECTION") private db: any) {}
async findAll() {
return this.db.query("SELECT * FROM users");
}
}
3.2 PipesβValidation and Transformation
Pipes process request data before it reaches controllers:
// common/pipes/validation.pipe.ts
import { ArgumentMetadata, BadRequestException, PipeTransform } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
export class ValidationPipe implements PipeTransform {
async transform(value: any, metadata: ArgumentMetadata) {
const targetType = metadata.type;
const dto = plainToInstance(targetType, value);
const errors = await validate(dto);
if (errors.length > 0) {
throw new BadRequestException({
message: "Validation failed",
errors: errors.map((err) => ({
field: err.property,
messages: Object.values(err.constraints || {}),
})),
});
}
return value;
}
}
// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength } from "class-validator";
export class CreateUserDTO {
@IsEmail()
email!: string;
@IsString()
@MinLength(2)
name!: string;
@IsString()
@MinLength(8)
password!: string;
}
// users/users.controller.ts
import { UsePipes } from "@nestjs/common";
import { ValidationPipe } from "../common/pipes/validation.pipe";
import { CreateUserDTO } from "./dto/create-user.dto";
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@UsePipes(new ValidationPipe())
async createUser(@Body() createUserDTO: CreateUserDTO) {
return this.usersService.create(createUserDTO);
}
}
3.3 GuardsβAuthentication and Authorization
Guards determine if a request should proceed:
// common/guards/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(" ")[1];
if (!token) {
throw new UnauthorizedException("Missing token");
}
try {
const decoded = this.jwtService.verify(token);
request.user = decoded;
return true;
} catch (err) {
throw new UnauthorizedException("Invalid token");
}
}
}
// common/guards/roles.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>("roles", context.getHandler());
if (!requiredRoles) return true; // No roles required
const request = context.switchToHttp().getRequest();
const userRole = request.user?.role;
if (!requiredRoles.includes(userRole)) {
throw new ForbiddenException("Insufficient permissions");
}
return true;
}
}
// common/decorators/roles.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);
// users/users.controller.ts
import { UseGuards } from "@nestjs/common";
import { Roles } from "../common/decorators/roles.decorator";
import { AuthGuard } from "../common/guards/auth.guard";
import { RolesGuard } from "../common/guards/roles.guard";
@Controller("users")
export class UsersController {
// ...
@Delete(":id")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
async deleteUser(@Param("id") id: string) {
return this.usersService.delete(parseInt(id));
}
}
3.4 InterceptorsβTransform Responses
Interceptors modify responses before sending them:
// common/interceptors/response.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { map } from "rxjs/operators";
interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: number;
}
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: Date.now(),
}))
);
}
}
// common/interceptors/logging.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
import { tap } from "rxjs/operators";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private logger = new Logger("HTTP");
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
this.logger.log(`${method} ${url} - ${duration}ms`);
})
);
}
}
// app.module.ts
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR } from "@nestjs/core";
import { ResponseInterceptor } from "./common/interceptors/response.interceptor";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],
})
export class AppModule {}
3.5 MiddlewareβRequest Processing
Middleware runs before guards/pipes:
// common/middleware/correlation-id.middleware.ts
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const correlationId = req.headers["x-correlation-id"] || `${Date.now()}:${Math.random()}`;
req.id = correlationId;
res.setHeader("x-correlation-id", correlationId);
next();
}
}
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from "@nestjs/common";
import { CorrelationIdMiddleware } from "./common/middleware/correlation-id.middleware";
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes("*");
}
}
Part 4: Building a Complete Feature
4.1 Blog Module Example
# Generate with CLI
nest generate module blog
nest generate controller blog
nest generate service blog
# Creates structure:
src/blog/
βββ blog.module.ts
βββ blog.controller.ts
βββ blog.service.ts
βββ dto/
βββ create-post.dto.ts
βββ update-post.dto.ts
// blog/dto/create-post.dto.ts
import { IsString, MinLength } from "class-validator";
export class CreatePostDTO {
@IsString()
@MinLength(5)
title!: string;
@IsString()
@MinLength(20)
content!: string;
}
// blog/blog.service.ts
import { Injectable } from "@nestjs/common";
import { CreatePostDTO } from "./dto/create-post.dto";
interface Post {
id: number;
title: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class BlogService {
private posts: Post[] = [];
private idCounter = 1;
async getAllPosts(): Promise<Post[]> {
return this.posts;
}
async getPostById(id: number): Promise<Post | null> {
return this.posts.find((p) => p.id === id) || null;
}
async createPost(data: CreatePostDTO): Promise<Post> {
const post: Post = {
id: this.idCounter++,
title: data.title,
content: data.content,
createdAt: new Date(),
updatedAt: new Date(),
};
this.posts.push(post);
return post;
}
async updatePost(id: number, data: Partial<CreatePostDTO>): Promise<Post | null> {
const post = this.posts.find((p) => p.id === id);
if (!post) return null;
Object.assign(post, data, { updatedAt: new Date() });
return post;
}
async deletePost(id: number): Promise<boolean> {
const index = this.posts.findIndex((p) => p.id === id);
if (index === -1) return false;
this.posts.splice(index, 1);
return true;
}
}
// blog/blog.controller.ts
import { Controller, Get, Post, Body, Param, Patch, Delete, UsePipes } from "@nestjs/common";
import { BlogService } from "./blog.service";
import { CreatePostDTO } from "./dto/create-post.dto";
import { ValidationPipe } from "../common/pipes/validation.pipe";
@Controller("blog/posts")
export class BlogController {
constructor(private readonly blogService: BlogService) {}
@Get()
async getAllPosts() {
return this.blogService.getAllPosts();
}
@Get(":id")
async getPostById(@Param("id") id: string) {
const post = await this.blogService.getPostById(parseInt(id));
if (!post) throw new Error("Post not found");
return post;
}
@Post()
@UsePipes(new ValidationPipe())
async createPost(@Body() createPostDTO: CreatePostDTO) {
return this.blogService.createPost(createPostDTO);
}
@Patch(":id")
@UsePipes(new ValidationPipe())
async updatePost(@Param("id") id: string, @Body() data: Partial<CreatePostDTO>) {
const post = await this.blogService.updatePost(parseInt(id), data);
if (!post) throw new Error("Post not found");
return post;
}
@Delete(":id")
async deletePost(@Param("id") id: string) {
const deleted = await this.blogService.deletePost(parseInt(id));
if (!deleted) throw new Error("Post not found");
return { message: "Post deleted" };
}
}
// blog/blog.module.ts
import { Module } from "@nestjs/common";
import { BlogController } from "./blog.controller";
import { BlogService } from "./blog.service";
@Module({
controllers: [BlogController],
providers: [BlogService],
exports: [BlogService],
})
export class BlogModule {}
// app.module.ts
import { Module } from "@nestjs/common";
import { BlogModule } from "./blog/blog.module";
@Module({
imports: [BlogModule],
})
export class AppModule {}
4.2 Database Integration
// database/database.service.ts
import { Injectable } from "@nestjs/common";
import { Pool } from "pg";
@Injectable()
export class DatabaseService {
private pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
});
async query(sql: string, params: any[] = []) {
const result = await this.pool.query(sql, params);
return result.rows;
}
async queryOne(sql: string, params: any[] = []) {
const result = await this.pool.query(sql, params);
return result.rows[0] || null;
}
async close() {
await this.pool.end();
}
}
// database/database.module.ts
import { Module } from "@nestjs/common";
import { DatabaseService } from "./database.service";
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
// blog/blog.service.ts (with database)
import { Injectable } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { CreatePostDTO } from "./dto/create-post.dto";
@Injectable()
export class BlogService {
constructor(private readonly db: DatabaseService) {}
async getAllPosts() {
return this.db.query("SELECT * FROM posts ORDER BY created_at DESC");
}
async getPostById(id: number) {
return this.db.queryOne("SELECT * FROM posts WHERE id = $1", [id]);
}
async createPost(data: CreatePostDTO) {
return this.db.queryOne(
"INSERT INTO posts (title, content, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *",
[data.title, data.content]
);
}
async updatePost(id: number, data: Partial<CreatePostDTO>) {
const updates = Object.keys(data)
.map((key, i) => `${key} = $${i + 2}`)
.join(",");
return this.db.queryOne(
`UPDATE posts SET ${updates}, updated_at = NOW() WHERE id = $1 RETURNING *`,
[id, ...Object.values(data)]
);
}
async deletePost(id: number) {
const result = await this.db.query("DELETE FROM posts WHERE id = $1 RETURNING id", [id]);
return result.length > 0;
}
}
Part 5: Testing
5.1 Unit Testing Services
// blog/blog.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { BlogService } from "./blog.service";
import { DatabaseService } from "../database/database.service";
describe("BlogService", () => {
let service: BlogService;
let mockDatabase: any;
beforeEach(async () => {
// Mock the database
mockDatabase = {
query: jest.fn(),
queryOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
BlogService,
{
provide: DatabaseService,
useValue: mockDatabase,
},
],
}).compile();
service = module.get<BlogService>(BlogService);
});
it("should create a post", async () => {
const post = {
id: 1,
title: "Test",
content: "Test content",
created_at: new Date(),
};
mockDatabase.queryOne.mockResolvedValue(post);
const result = await service.createPost({ title: "Test", content: "Test content" });
expect(result).toEqual(post);
expect(mockDatabase.queryOne).toHaveBeenCalled();
});
});
5.2 E2E Testing
// test/blog.e2e-spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "../src/app.module";
describe("Blog (e2e)", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it("should create and retrieve a post", async () => {
const createResponse = await request(app.getHttpServer())
.post("/blog/posts")
.send({ title: "Test Post", content: "This is a test post content" })
.expect(201);
const postId = createResponse.body.id;
const getResponse = await request(app.getHttpServer())
.get(`/blog/posts/${postId}`)
.expect(200);
expect(getResponse.body.title).toBe("Test Post");
});
});
π― NestJS Best Practices Checklist
β
Use modules to organize features (one feature = one module)
β
Keep business logic in services, not controllers
β
Use DTOs for input validation
β
Use pipes for validation (@UsePipes)
β
Use guards for authentication/authorization
β
Use interceptors for response transformation
β
Implement error handling globally (exception filters)
β
Inject dependencies, don't create them manually
β
Write unit tests for services
β
Write e2e tests for critical flows
β
Use environment variables for config
β
Never commit secrets
β Don't put logic in controllers
β Don't mix concerns (service should not know HTTP)
β Don't manually instantiate providers
β Don't skip validation
β Don't leave error handling to Express
β Don't test implementation details
Conclusion π
NestJS isnβt magicβitβs well-organized conventions enforced by the framework. Modules keep code organized. Services contain logic. Controllers handle HTTP. Guards protect routes. Pipes validate input.
Start small with a single module. Build a feature completely. Then duplicate the pattern for the next feature. This is how NestJS scales from solo projects to enterprise applications.
The structure you write today is the structure that scales effortlessly. Thatβs why NestJS matters. πͺ