NestJS from Beginner to Expert: A Learning Path
NestJS is a progressive, opinionated Node.js framework for building efficient, scalable server-side applications. It leverages TypeScript by default and draws heavy inspiration from Angular's architecture, bringing proven design patterns and best practices to the backend world. This tutorial maps out a complete learning path—from your first controller to advanced enterprise patterns—with practical code examples at every step.
What Is NestJS and Why It Matters
NestJS sits on top of Express (or Fastify) and provides a structured, modular architecture out of the box. It embraces decorators, dependency injection, and modules—concepts that make code testable, maintainable, and scalable. Unlike raw Express apps where structure is left entirely to the developer, NestJS gives you a proven blueprint.
Core Concepts That Set NestJS Apart
- Modules – Organize code into cohesive, encapsulated blocks with clear boundaries
- Controllers – Handle incoming HTTP requests and return responses
- Providers / Services – Contain business logic and are injectable via Nest's DI container
- Guards – Act as middleware for authorization and authentication checks
- Pipes – Validate and transform incoming data before it reaches route handlers
- Interceptors – Transform responses, add logging, or cache results transparently
- Filters – Catch and format exceptions globally or per-route
Why Choose NestJS Over Raw Express?
Express gives you freedom, but freedom at scale becomes chaos. NestJS provides conventions that teams can follow, making onboarding faster and code reviews simpler. It also integrates first-class support for GraphQL, WebSockets, microservices, Prisma, TypeORM, Mongoose, Swagger, Jest testing, and more—all under one cohesive ecosystem.
Setting Up Your First NestJS Project
Install the NestJS CLI globally, then scaffold a new project. The CLI generates the boilerplate module, controller, and service structure automatically.
# Install the CLI globally
npm install -g @nestjs/cli
# Create a new project
nest new my-first-api
# Choose npm or yarn, then enter the directory
cd my-first-api
# Start development server on port 3000
npm run start:dev
The generated project structure looks like this:
src/
app.module.ts # Root module that imports everything
app.controller.ts # Basic controller with a GET endpoint
app.service.ts # Injectable service with business logic
main.ts # Bootstrap function that starts the app
Phase 1: Beginner – Controllers, Routes, and HTTP Methods
Controllers are the entry point for HTTP requests. You define methods decorated with @Get(), @Post(), @Put(), @Patch(), @Delete() to handle different HTTP verbs.
Building Your First REST Controller
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
@Controller('tasks')
export class TasksController {
// GET /tasks – returns all tasks
@Get()
findAll(@Query('status') status?: string) {
// Query params are automatically extracted
return { status: status || 'all', tasks: [] };
}
// GET /tasks/:id – returns a single task
@Get(':id')
findOne(@Param('id') id: string) {
return { id, title: `Task ${id}` };
}
// POST /tasks – creates a new task
@Post()
create(@Body() payload: { title: string; description?: string }) {
return { id: Math.random().toString(36).substr(2, 9), ...payload };
}
}
Controller Best Practices for Beginners
- Keep controllers thin—delegate logic to services immediately
- Use DTO classes (Data Transfer Objects) instead of generic
@Body()objects - Prefix routes with a plural noun like
'tasks'for consistency - Return plain objects or promises—NestJS handles serialization automatically
Phase 2: Intermediate – Providers, Services, and Dependency Injection
Services encapsulate business logic and data access. NestJS uses its own DI (Dependency Injection) container, which is inspired by Angular. Any class annotated with @Injectable() can be injected into controllers, other services, or any injectable context.
Creating a Service and Injecting It
import { Injectable, NotFoundException } from '@nestjs/common';
interface Task {
id: string;
title: string;
done: boolean;
}
@Injectable()
export class TasksService {
private tasks: Task[] = [];
findAll(): Task[] {
return this.tasks;
}
findOne(id: string): Task {
const task = this.tasks.find(t => t.id === id);
if (!task) throw new NotFoundException(`Task ${id} not found`);
return task;
}
create(title: string): Task {
const task = { id: Date.now().toString(), title, done: false };
this.tasks.push(task);
return task;
}
toggleDone(id: string): Task {
const task = this.findOne(id);
task.done = !task.done;
return task;
}
}
Now inject this service into your controller via the constructor:
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
// NestJS automatically resolves and injects TasksService
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll() {
return this.tasksService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tasksService.findOne(id);
}
@Post()
create(@Body('title') title: string) {
return this.tasksService.create(title);
}
}
Registering the Service in a Module
Every provider must belong to a module. You register it in the providers array:
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService], // <-- service registered here
})
export class TasksModule {}
Then import TasksModule into the root AppModule:
import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [TasksModule],
})
export class AppModule {}
Phase 3: Advanced Intermediate – DTOs, Validation, and Pipes
NestJS integrates seamlessly with class-validator and class-transformer to validate incoming data declaratively.
Installing Validation Dependencies
npm install class-validator class-transformer
Creating a DTO with Validation Rules
import { IsString, IsOptional, MinLength, IsBoolean } from 'class-validator';
export class CreateTaskDto {
@IsString()
@MinLength(3, { message: 'Title must be at least 3 characters' })
title: string;
@IsOptional()
@IsString()
description?: string;
}
export class UpdateTaskDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsBoolean()
done?: boolean;
}
Applying Global Validation Pipes
In main.ts, add a global ValidationPipe to automatically validate all incoming requests:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global pipe that strips unknown properties and validates DTOs
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip non-whitelisted properties
forbidNonWhitelisted: true, // Throw error on unknown fields
transform: true, // Auto-transform payloads to DTO instances
}),
);
await app.listen(3000);
}
bootstrap();
Now your controller automatically receives validated, typed DTO instances:
@Post()
create(@Body() createTaskDto: CreateTaskDto) {
// createTaskDto is already a validated CreateTaskDto instance
return this.tasksService.create(createTaskDto.title);
}
Phase 4: Database Integration with Prisma ORM
Prisma is the recommended ORM for NestJS due to its type safety, migrations, and excellent developer experience. Let's integrate it.
Setting Up Prisma in a NestJS Project
npm install prisma @prisma/client
npx prisma init
# This creates a prisma/ directory with schema.prisma
Defining the Schema
// prisma/schema.prisma
datasource db {
provider = "postgresql" // or "sqlite", "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Task {
id String @id @default(cuid())
title String
description String?
done Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Creating a NestJS Prisma Service (Singleton)
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
Register it in a shared module so other modules can import it:
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService], // Make it available to importing modules
})
export class PrismaModule {}
Refactoring TasksService to Use Prisma
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTaskDto, UpdateTaskDto } from './dto';
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.task.findMany();
}
findOne(id: string) {
return this.prisma.task.findUnique({ where: { id } });
}
create(dto: CreateTaskDto) {
return this.prisma.task.create({ data: dto });
}
update(id: string, dto: UpdateTaskDto) {
return this.prisma.task.update({ where: { id }, data: dto });
}
remove(id: string) {
return this.prisma.task.delete({ where: { id } });
}
}
Phase 5: Authentication and Authorization with Guards
Authentication in NestJS typically uses Passport.js strategies wrapped in NestJS guards. Let's implement JWT authentication.
Installing Auth Dependencies
npm install @nestjs/passport @nestjs/jwt passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt
Creating JWT Strategy and Guard
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: { sub: string; email: string }) {
// This payload is the decoded JWT token
return { userId: payload.sub, email: payload.email };
}
}
Protecting Routes with Guards
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller('profile')
export class ProfileController {
@UseGuards(AuthGuard('jwt'))
@Get()
getProfile(@Req() req) {
// req.user is populated by JwtStrategy's validate()
return req.user;
}
}
Creating a Custom Decorator for Cleaner Code
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
// Usage in controller:
@Get()
getProfile(@CurrentUser() user: { userId: string; email: string }) {
return user;
}
Phase 6: Advanced – Interceptors, Custom Pipes, and Exception Filters
Building a Response Transformation Interceptor
import {
Injectable, NestInterceptor, ExecutionContext, CallHandler
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ApiResponse {
success: boolean;
data: T;
timestamp: string;
path: string;
}
@Injectable()
export class TransformInterceptor
implements NestInterceptor>
{
intercept(context: ExecutionContext, next: CallHandler): Observable> {
const request = context.switchToHttp().getRequest();
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
path: request.url,
})),
);
}
}
Apply it globally in main.ts:
app.useGlobalInterceptors(new TransformInterceptor());
Custom Validation Pipe Example
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParsePositiveIntPipe implements PipeTransform {
transform(value: any) {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 0) {
throw new BadRequestException(
`Expected a positive integer, received: ${value}`
);
}
return parsed;
}
}
// Usage:
@Get(':id')
findOne(@Param('id', ParsePositiveIntPipe) id: number) {
// id is now guaranteed to be a positive number
}
Global Exception Filter
import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
let status = 500;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
message = exception.message;
}
this.logger.error(
`${request.method} ${request.url} → ${status}`,
exception instanceof Error ? exception.stack : '',
);
response.status(status).json({
success: false,
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Phase 7: Expert – Module Architecture and Domain-Driven Design
As applications grow, module organization becomes critical. NestJS supports feature modules, shared modules, and dynamic modules. An expert-level codebase organizes modules around business domains, not technical layers.
Recommended Module Structure
src/
modules/
tasks/
tasks.module.ts
tasks.controller.ts
tasks.service.ts
dto/
create-task.dto.ts
update-task.dto.ts
entities/
task.entity.ts # Domain entity (not ORM entity)
tasks.repository.ts # Abstract repository interface
users/
users.module.ts
users.controller.ts
users.service.ts
dto/
entities/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
strategies/
jwt.strategy.ts
guards/
jwt-auth.guard.ts
common/
decorators/
current-user.decorator.ts
filters/
global-exception.filter.ts
interceptors/
transform.interceptor.ts
pipes/
parse-positive-int.pipe.ts
config/
configuration.ts # Typed config with ConfigService
prisma/
prisma.module.ts
prisma.service.ts
Repository Pattern for Database Abstraction
Abstract the database access behind a repository interface. This makes unit testing trivial and allows swapping implementations.
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Task } from './entities/task.entity';
export interface ITaskRepository {
findAll(): Promise;
findById(id: string): Promise;
create(task: Partial): Promise;
update(id: string, task: Partial): Promise;
delete(id: string): Promise;
}
@Injectable()
export class TaskRepository implements ITaskRepository {
constructor(private readonly prisma: PrismaService) {}
async findAll(): Promise {
return this.prisma.task.findMany();
}
async findById(id: string): Promise {
return this.prisma.task.findUnique({ where: { id } });
}
async create(task: Partial): Promise {
return this.prisma.task.create({ data: task });
}
async update(id: string, task: Partial): Promise {
return this.prisma.task.update({ where: { id }, data: task });
}
async delete(id: string): Promise {
await this.prisma.task.delete({ where: { id } });
}
}
Use NestJS custom providers to inject the interface:
@Module({
controllers: [TasksController],
providers: [
TasksService,
{ provide: 'ITaskRepository', useClass: TaskRepository },
],
})
export class TasksModule {}
Domain Entity vs ORM Entity
Keep a pure domain entity separate from the ORM model. This prevents your business logic from coupling to the database layer:
// Domain entity – pure business rules, no ORM dependency
export class Task {
constructor(
public readonly id: string,
public title: string,
public description: string | null,
public done: boolean,
public readonly createdAt: Date,
public updatedAt: Date,
) {}
// Business logic lives on the entity
toggle(): void {
this.done = !this.done;
// Could emit domain events here
}
rename(newTitle: string): void {
if (newTitle.length < 3) {
throw new Error('Title too short');
}
this.title = newTitle;
}
}
Phase 8: Testing – Unit, Integration, and E2E
NestJS has first-class Jest support with utilities for testing controllers, services, and full HTTP flows.
Unit Testing a Service
import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './tasks.service';
import { NotFoundException } from '@nestjs/common';
const mockTaskRepository = {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
};
describe('TasksService', () => {
let service: TasksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TasksService,
{ provide: 'ITaskRepository', useValue: mockTaskRepository },
],
}).compile();
service = module.get(TasksService);
});
it('should throw NotFoundException when task does not exist', async () => {
mockTaskRepository.findById.mockResolvedValue(null);
await expect(service.findOne('non-existent')).rejects.toThrow(
NotFoundException,
);
});
it('should return a task when found', async () => {
const task = { id: '1', title: 'Test', done: false };
mockTaskRepository.findById.mockResolvedValue(task);
const result = await service.findOne('1');
expect(result).toEqual(task);
});
});
E2E Testing with SuperTest
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('TasksController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
});
it('POST /tasks – creates a task', () => {
return request(app.getHttpServer())
.post('/tasks')
.send({ title: 'E2E Task' })
.expect(201)
.expect(res => {
expect(res.body.data.title).toBe('E2E Task');
});
});
afterAll(async () => {
await app.close();
});
});
Phase 9: Expert – Microservices, GraphQL, and WebSockets
NestJS is not just for REST APIs. It treats transport layers as swappable adapters. You can expose the same service logic via REST, GraphQL, WebSocket, or microservice message patterns with minimal changes.
GraphQL (Code-First Approach)
npm install @nestjs/graphql @nestjs/apollo graphql apollo-server-express
// Define a resolver with NestJS decorators
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { TasksService } from './tasks.service';
import { Task } from './entities/task.entity';
import { CreateTaskInput } from './dto/create-task.input';
@Resolver(() => Task)
export class TasksResolver {
constructor(private readonly tasksService: TasksService) {}
@Query(() => [Task])
tasks() {
return this.tasksService.findAll();
}
@Mutation(() => Task)
createTask(@Args('input') input: CreateTaskInput) {
return this.tasksService.create(input);
}
}
Microservices with NATS
// In main.ts, switch from HTTP to microservice transport
const app = await NestFactory.createMicroservice(AppModule, {
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
},
});
await app.listen();
// Controller now uses message patterns instead of HTTP verbs
@Controller()
export class MathController {
@MessagePattern('math.sum')
sum(@Payload() data: number[]) {
return data.reduce((a, b) => a + b, 0);
}
}
WebSocket Gateway
import { WebSocketGateway, SubscribeMessage, WsResponse } from '@nestjs/websockets';
@WebSocketGateway()
export class EventsGateway {
@SubscribeMessage('events')
handleEvent(client: any, data: any): WsResponse {
return { event: 'events', data: `Echo: ${data.message}` };
}
}
Best Practices Summary
- Always use DTOs – Never trust raw request bodies; validate and transform them with class-validator
- Keep controllers thin – One line of delegation to a service is ideal; business logic lives in services and domain entities
- Use the repository pattern – Abstract database access behind an interface for testability and future-proofing
- Leverage custom decorators –
@CurrentUser(),@Public(), etc. reduce boilerplate and improve readability - Global pipes and filters early – Set up ValidationPipe, TransformInterceptor, and GlobalExceptionFilter in
main.tsfrom day one - Module boundaries matter – Only export what other modules truly need; keep internals private with barrel files (index.ts)
- Test everything – Unit test services with mocked dependencies; E2E test critical paths with a real database
- Use ConfigService and environment-specific configuration – Never hardcode secrets or environment-dependent values
- Embrace TypeScript strictly – Enable strict mode in tsconfig.json; use discriminated unions for complex state
- Log and monitor – Use NestJS Logger (or inject a custom one) consistently; never use console.log in production
Conclusion
The NestJS learning path from beginner to expert is a journey of progressive structure adoption. You start with simple controllers and services, add validation pipes, integrate a database with Prisma, secure endpoints with guards, and gradually layer in interceptors, filters, and the repository pattern. By the time you reach expert level, you're building domain-driven, testable, transport-agnostic applications that can expose the same logic via REST, GraphQL, WebSockets, or microservices. NestJS gives you the conventions; the craft lies in how you compose them. The framework rewards discipline—every decorator, every module boundary, and every test you write compounds into a codebase that scales with your team and your ambitions.