NestJS is a progressive Node.js framework built with TypeScript that brings architectural patterns familiar from enterprise Java (Spring Boot, Angular) into the Node ecosystem. It uses decorators, dependency injection, and a modular architecture to produce maintainable, testable, and scalable server-side applications.
| Building Block | Responsibility | Decorator |
|---|---|---|
| Controller | Handle HTTP requests, define routes | @Controller() |
| Provider / Service | Business logic, injectable dependencies | @Injectable() |
| Module | Organise and group related code | @Module() |
| Middleware | Pre-processing of requests | Class or function |
| Guard | Authorization — can this request proceed? | @UseGuards() |
| Pipe | Validation and transformation of input | @UsePipes() |
| Interceptor | Wrap request/response logic | @UseInterceptors() |
| Exception Filter | Handle and transform errors | @UseFilters() |
src/
├── main.ts ← Bootstrap entry point
├── app.module.ts ← Root module
├── tasks/
│ ├── tasks.module.ts ← Feature module
│ ├── tasks.controller.ts ← Route handlers
│ ├── tasks.service.ts ← Business logic
│ ├── dto/
│ │ ├── create-task.dto.ts
│ │ └── update-task.dto.ts
│ └── entities/
│ └── task.entity.ts
└── common/
├── filters/
│ └── http-exception.filter.ts
├── guards/
│ └── api-key.guard.ts
└── interceptors/
└── logging.interceptor.ts
npm install -g @nestjs/cli
nest --version # should print 10.x.x
nest new devtask-api
cd devtask-api
npm run start:dev # starts on http://localhost:3000
Hit http://localhost:3000 — you should see Hello World!
Open src/app.module.ts, src/app.controller.ts, and src/main.ts. Notice:
NestFactory.create(AppModule) — the entry point wires everything together@Module() decorator connects controllers and providers@Controller() and @Get() define the routenest generate module tasks
nest generate controller tasks
nest generate service tasks
Check app.module.ts — NestJS automatically imported TasksModule.
src/tasks/entities/task.entity.ts
export enum TaskStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
DONE = 'DONE',
}
export interface Task {
id: string;
title: string;
description: string;
status: TaskStatus;
createdAt: Date;
}
Which NestJS building block is responsible for authorization — deciding whether a request should proceed?
CanActivate interface and return true/false to allow or reject a request before it reaches the controller.In the NestJS request lifecycle, what runs before Guards?
What does nest generate module tasks automatically do to app.module.ts?
TasksModule into the imports array of AppModule automatically — no manual wiring needed.Controllers handle incoming HTTP requests and return responses. They define the route structure of your API using decorators. A controller should only handle routing and input extraction — all business logic belongs in a service.
| Decorator | HTTP Method | Usage |
|---|---|---|
@Get(path?) | GET | Retrieve resources |
@Post(path?) | POST | Create resources |
@Put(path?) | PUT | Replace a resource |
@Patch(path?) | PATCH | Partial update |
@Delete(path?) | DELETE | Remove a resource |
| Decorator | Extracts |
|---|---|
@Param('id') | Route parameter (/tasks/:id) |
@Body() | Request body (parsed JSON) |
@Query('status') | Query string param (?status=OPEN) |
@Headers('x-api-key') | Request header value |
@Req() | Full Express request object |
NestJS returns 200 for GET/DELETE/PATCH and 201 for POST by default. Override with @HttpCode(204).
@Post()
@HttpCode(HttpStatus.CREATED) // explicit, but 201 is default for POST
async create(@Body() dto: CreateTaskDto): Promise<Task> { ... }
import {
Controller, Get, Post, Patch, Delete,
Param, Body, Query, HttpCode, HttpStatus,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { Task, TaskStatus } from './entities/task.entity';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
// GET /tasks
// GET /tasks?status=OPEN
@Get()
findAll(@Query('status') status?: TaskStatus): Task[] {
return this.tasksService.findAll(status);
}
// GET /tasks/:id
@Get(':id')
findOne(@Param('id') id: string): Task {
return this.tasksService.findOne(id);
}
// POST /tasks
@Post()
create(@Body() body: { title: string; description: string }): Task {
return this.tasksService.create(body.title, body.description);
}
// PATCH /tasks/:id/status
@Patch(':id/status')
updateStatus(
@Param('id') id: string,
@Body('status') status: TaskStatus,
): Task {
return this.tasksService.updateStatus(id, status);
}
// DELETE /tasks/:id
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string): void {
this.tasksService.remove(id);
}
}
# Create a task
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Set up CI/CD","description":"Configure GitHub Actions pipeline"}'
# List all tasks
curl http://localhost:3000/tasks
# Filter by status
curl "http://localhost:3000/tasks?status=OPEN"
You want to extract ?assignee=john from the URL in a controller method. Which decorator do you use?
@Query('assignee') extracts from the query string.A DELETE /tasks/:id endpoint should return no body. What status code and decorator should you use?
@HttpCode(HttpStatus.NO_CONTENT) — returns HTTP 204. NestJS would default to 200 without this.What is wrong with this controller method?
@Get(':id')
findOne(@Param('id') id: string) {
const result = this.db.query(`SELECT * FROM tasks WHERE id = ${id}`);
return result;
}
Add a GET /tasks/stats route that returns the count of tasks in each status. Consider: where should this route be placed relative to GET /tasks/:id and why?
/tasks/stats placed after /tasks/:id would match :id = "stats" instead of the stats route. Place specific routes before parameterised ones.A provider is any class annotated with @Injectable(). NestJS manages its lifecycle and can inject it into any other class that declares it as a constructor dependency. This is the Dependency Injection (DI) pattern.
new, it declares what it needs and the framework provides it — making code testable and decoupled.| Scope | Behaviour | Use Case |
|---|---|---|
DEFAULT (Singleton) | One instance per application | Services, repositories (default) |
REQUEST | New instance per request | Request-scoped state |
TRANSIENT | New instance per consumer | Lightweight, stateless utilities |
Beyond simple classes, NestJS supports value providers, factory providers, and alias providers for advanced DI scenarios:
// Value provider — inject a config object
{ provide: 'APP_CONFIG', useValue: { maxTasks: 100 } }
// Factory provider — computed at bootstrap
{ provide: 'DB_CONNECTION', useFactory: () => createConnection() }
// Alias — inject one token as another
{ provide: TasksService, useExisting: CachedTasksService }
import { Injectable, NotFoundException } from '@nestjs/common';
import { Task, TaskStatus } from './entities/task.entity';
import { v4 as uuid } from 'uuid';
@Injectable()
export class TasksService {
private tasks: Task[] = [];
findAll(status?: TaskStatus): Task[] {
if (status) {
return this.tasks.filter((t) => t.status === status);
}
return this.tasks;
}
findOne(id: string): Task {
const task = this.tasks.find((t) => t.id === id);
if (!task) {
throw new NotFoundException(`Task with ID "${id}" not found`);
}
return task;
}
create(title: string, description: string): Task {
const task: Task = {
id: uuid(),
title,
description,
status: TaskStatus.OPEN,
createdAt: new Date(),
};
this.tasks.push(task);
return task;
}
updateStatus(id: string, status: TaskStatus): Task {
const task = this.findOne(id); // reuses findOne — throws if not found
task.status = status;
return task;
}
remove(id: string): void {
this.findOne(id); // throws NotFoundException if missing
this.tasks = this.tasks.filter((t) => t.id !== id);
}
}
npm install uuid
npm install -D @types/uuid
# Create two tasks
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" \
-d '{"title":"Write unit tests","description":"Cover service layer"}'
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" \
-d '{"title":"Code review","description":"Review open PRs"}'
# List all — note the IDs
curl http://localhost:3000/tasks
# Update status (replace ID)
curl -X PATCH http://localhost:3000/tasks/<ID>/status \
-H "Content-Type: application/json" \
-d '{"status":"IN_PROGRESS"}'
# Delete
curl -X DELETE http://localhost:3000/tasks/<ID>
What happens if you remove @Injectable() from TasksService?
In TasksService.findOne() we throw NotFoundException. Where does this get caught and converted to a 404 HTTP response?
HttpException subclasses (including NotFoundException) and maps them to the correct HTTP status code and response body automatically.If TasksService is DEFAULT scope (singleton), and 1,000 concurrent requests hit your API, how many instances of TasksService exist?
Modules are the primary organisational unit in NestJS. Every application has at least one root module (AppModule). Feature modules encapsulate related controllers, services, and other providers into a cohesive unit.
@Module({
imports: [], // other modules whose exported providers you need
controllers: [], // controllers that belong to this module
providers: [], // services and providers instantiated by this module
exports: [], // providers made available to importing modules
})
Mark a module @Global() to make its exports available everywhere without importing it — useful for logging, config, or database connections. Use sparingly.
nest generate module users
nest generate controller users
nest generate service users
import { Injectable } from '@nestjs/common';
export interface User { id: string; username: string; }
@Injectable()
export class UsersService {
private users: User[] = [
{ id: 'u1', username: 'alice' },
{ id: 'u2', username: 'bob' },
];
findByUsername(username: string): User | undefined {
return this.users.find((u) => u.username === username);
}
}
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // ← makes UsersService available to other modules
})
export class UsersModule {}
@Module({
imports: [UsersModule], // ← now TasksService can inject UsersService
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
You add UsersService to TasksModule.providers instead of importing UsersModule. What is the problem?
When should you use @Global()?
A Data Transfer Object is a class that defines the shape and validation rules of data flowing into your API. Instead of trusting raw @Body() objects, you define exactly what fields are expected and what rules they must satisfy.
Pipes operate on the arguments of a controller method before the method is invoked. They have two primary uses: validation (throw if invalid) and transformation (convert types).
| Built-in Pipe | Purpose |
|---|---|
ValidationPipe | Validates incoming data against a DTO using class-validator |
ParseIntPipe | Converts string route param to integer |
ParseUUIDPipe | Validates that a param is a valid UUID |
ParseEnumPipe | Validates value is a member of an enum |
DefaultValuePipe | Provides a default when value is undefined |
// Global — applied to every route
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
// Controller — applied to all routes in the controller
@UsePipes(new ValidationPipe())
@Controller('tasks')
// Method — applied to one route
@Post()
@UsePipes(new ValidationPipe())
// Parameter — applied to one argument
@Param('id', ParseUUIDPipe) id: string
npm install class-validator class-transformer
import { IsString, IsNotEmpty, MinLength, MaxLength, IsOptional } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
@MinLength(3)
@MaxLength(100)
title: string;
@IsString()
@IsOptional()
@MaxLength(500)
description?: string;
}
import { IsEnum } from 'class-validator';
import { TaskStatus } from '../entities/task.entity';
export class UpdateTaskStatusDto {
@IsEnum(TaskStatus)
status: TaskStatus;
}
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties not in DTO
forbidNonWhitelisted: true, // throw if extra properties sent
transform: true, // auto-transform primitives to their TS types
}),
);
await app.listen(3000);
}
bootstrap();
@Post()
create(@Body() createTaskDto: CreateTaskDto): Task {
return this.tasksService.create(createTaskDto);
}
@Patch(':id/status')
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTaskStatusDto,
): Task {
return this.tasksService.updateStatus(id, dto.status);
}
# Should fail — title too short
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"CI"}'
# Should fail — invalid status
curl -X PATCH http://localhost:3000/tasks/some-id/status \
-H "Content-Type: application/json" \
-d '{"status":"INVALID_STATUS"}'
# Should fail — extra property stripped/rejected
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Valid title","description":"desc","hacked":true}'
What does whitelist: true on ValidationPipe do?
whitelist: true removes any properties from the request body that don't have a corresponding decorator in the DTO, protecting against mass assignment attacks.You have @Param('id') id: string but want to ensure id is a valid UUID before it reaches your service. What is the cleanest solution?
@Param('id', ParseUUIDPipe) id: string — the pipe runs first, throws a 400 Bad Request if invalid, and your service only ever receives a valid UUID.Why is transform: true useful when your route param :page is declared as page: number in the method signature?
transform: true, ValidationPipe automatically converts "3" to 3 based on the TypeScript type annotation — without needing ParseIntPipe.Create a TrimStringPipe that trims whitespace from any string value before it reaches the controller. Apply it globally so that " Write tests " becomes "Write tests" automatically.
// Starter scaffold
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class TrimStringPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// Your implementation here
}
}
NestJS ships with a set of HttpException subclasses that map to standard HTTP status codes. Throw any of these from a service or controller and the global exception layer converts them to a proper JSON response.
| Exception Class | Status |
|---|---|
BadRequestException | 400 |
UnauthorizedException | 401 |
ForbiddenException | 403 |
NotFoundException | 404 |
ConflictException | 409 |
UnprocessableEntityException | 422 |
InternalServerErrorException | 500 |
When you need a consistent error envelope, custom error codes, or to log errors before responding, implement a custom ExceptionFilter.
import {
ExceptionFilter, Catch, ArgumentsHost,
HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { Request, 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<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
const errorBody = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message:
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message,
};
this.logger.warn(`[${request.method}] ${request.url} → ${status}`);
response.status(status).json(errorBody);
}
}
app.useGlobalFilters(new HttpExceptionFilter());
# Should return structured error with timestamp and path
curl http://localhost:3000/tasks/non-existent-id
You should now see a consistent JSON envelope:
{
"statusCode": 404,
"timestamp": "2026-05-06T14:23:11.000Z",
"path": "/tasks/non-existent-id",
"method": "GET",
"message": "Task with ID \"non-existent-id\" not found"
}
An unhandled TypeError: Cannot read properties of undefined is thrown in a service. Which filter catches it?
HttpException. To catch everything, use @Catch() with no arguments.How do you create a domain-specific exception that always returns 409 Conflict?
ConflictException or HttpException: export class TaskAlreadyExistsException extends ConflictException { constructor(title: string) { super(\`Task "${title}" already exists\`); } }Guards implement the CanActivate interface. They run after middleware but before interceptors and pipes. A guard returns true (proceed) or false / throws an exception (reject with 403).
Guards are the right place for authorization logic — authentication (who are you?) is typically middleware, but authorization (are you allowed to do this?) is a guard.
Guards receive an ExecutionContext that lets you inspect the request, extract metadata, and adapt to different transport layers (HTTP, WebSockets, gRPC).
const request = context.switchToHttp().getRequest();
const handler = context.getHandler(); // the route method
const classRef = context.getClass(); // the controller class
// Use Reflector to read custom metadata on the handler or class
Combine guards with @SetMetadata() to create role-based access control:
// Define a decorator
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// Use it on a route
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) { ... }
// Read it in a guard
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
import {
CanActivate, ExecutionContext, Injectable, UnauthorizedException,
} from '@nestjs/common';
@Injectable()
export class ApiKeyGuard implements CanActivate {
private readonly validKey = 'devtask-secret-key-2026'; // in real apps: ConfigService
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const apiKey = request.headers['x-api-key'];
if (!apiKey || apiKey !== this.validKey) {
throw new UnauthorizedException('Invalid or missing API key');
}
return true;
}
}
import { UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@Controller('tasks')
@UseGuards(ApiKeyGuard) // protects all routes in this controller
export class TasksController { ... }
# Should fail — 401
curl http://localhost:3000/tasks
# Should succeed — 200
curl http://localhost:3000/tasks \
-H "x-api-key: devtask-secret-key-2026"
# POST with key
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-H "x-api-key: devtask-secret-key-2026" \
-d '{"title":"Implement auth guard","description":"Done!"}'
You apply @UseGuards(ApiKeyGuard) at the controller level but want one specific route to be public. What is the cleanest approach?
@Public() decorator using SetMetadata('isPublic', true), then in the guard check this.reflector.get('isPublic', context.getHandler()) and return true early if the route is marked public.What is the difference between returning false and throwing UnauthorizedException in a guard?
false causes NestJS to throw a generic ForbiddenException (403). Throwing UnauthorizedException gives you a 401 with a custom message — more appropriate for missing/invalid credentials.Extend the guard concept: create a RolesGuard that reads a @Roles('admin') decorator from the route. The DELETE /tasks/:id route should only be accessible to admins. Pass the role in a custom header x-user-role.
Interceptors wrap the request/response cycle using RxJS Observables. They can:
@Injectable()
export class MyInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Runs BEFORE the handler
return next
.handle() // calls the route handler
.pipe(
map(data => ({ success: true, data })), // transforms the response
tap(() => /* runs after handler, logs etc */),
);
}
}
| Feature | Middleware | Guard | Interceptor |
|---|---|---|---|
| Runs before handler | ✅ | ✅ | ✅ |
| Access to response | ✅ | ❌ | ✅ |
| Can modify response | ❌ | ❌ | ✅ |
| Can abort request | ✅ | ✅ | ✅ |
| NestJS DI support | ✅ | ✅ | ✅ |
| Typical use | Logging, CORS, body-parse | Auth, roles | Logging, caching, transform |
import {
Injectable, NestInterceptor, ExecutionContext,
CallHandler, Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const { method, url } = req;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const ms = Date.now() - start;
this.logger.log(`${method} ${url} — ${ms}ms`);
}),
);
}
}
import {
Injectable, NestInterceptor, ExecutionContext, CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
app.useGlobalInterceptors(
new LoggingInterceptor(),
new TransformInterceptor(),
);
curl http://localhost:3000/tasks -H "x-api-key: devtask-secret-key-2026"
All responses are now wrapped:
{
"success": true,
"data": [...],
"timestamp": "2026-05-06T15:10:00.000Z"
}
Your TransformInterceptor wraps all responses in { success: true, data: ... }. But your HttpExceptionFilter returns { statusCode, message, ... } directly. Will exceptions be double-wrapped?
next.handle() observable errors out, and the exception filter takes over directly, bypassing the interceptor's map() operator.You want to add a Cache-Control: max-age=60 header to GET /tasks responses only. Should you use middleware or an interceptor, and why?
Implement a TimeoutInterceptor that cancels any request taking longer than 5 seconds and throws a RequestTimeoutException. Hint: use RxJS timeout operator and catch the TimeoutError.
import { timeout, catchError } from 'rxjs/operators';
import { TimeoutError, throwError } from 'rxjs';
import { RequestTimeoutException } from '@nestjs/common';
// In your intercept method:
return next.handle().pipe(
timeout(5000),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException());
}
return throwError(() => err);
}),
);
You have all the building blocks. Complete the DevTask API by combining everything you've learned:
Add an assignedTo field to tasks. When creating a task, validate the username exists in UsersService. Throw a BadRequestException if the username doesn't exist.
Move the API key from a hardcoded string to an environment variable using NestJS ConfigModule. Install @nestjs/config and inject ConfigService into your guard.
Add ?page=1&limit=10 support to GET /tasks. Create a PaginationDto with @IsOptional(), @IsInt(), @Min(1), and @Max(100) decorators. Return paginated results with total, page, limit, and data fields.
Create an AuditInterceptor that logs every POST, PATCH, and DELETE request to an in-memory audit log with: timestamp, method, url, and apiKey (from request header). Expose GET /audit to retrieve the audit log (admin only).
nest new <app> # scaffold new project
nest generate module <name> # feature module
nest generate controller <name> # controller (+ spec)
nest generate service <name> # service (+ spec)
nest generate guard <name> # guard
nest generate interceptor <name> # interceptor
nest generate pipe <name> # pipe
nest generate filter <name> # exception filter
nest build # compile TypeScript
nest start:dev # watch mode with hot reload