← Back to DevBytes

NestJS from Beginner to Expert: A Learning Path

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

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles