NestJS — Fundamentals
This lecture rebuilds the same task API from lectures 66–67 using NestJS. Same endpoints, same domain (tasks, categories, users) — different framework. Where Express gave you a blank canvas, NestJS gives you architecture: modules, dependency injection, decorators, and a clear request lifecycle. The tradeoff is more files and more boilerplate, but the structure pays off as your project grows.
Lecture roadmap:
- Project setup — CLI scaffold, project structure
- Architecture — modules, controllers, services, dependency injection
- DTOs & validation — class-validator, ValidationPipe
- CRUD — tasks and categories with in-memory storage
- Exception handling — built-in exceptions, global filter
- Middleware lifecycle — pipes, guards, interceptors
- Configuration & CORS — ConfigModule, environment variables
Why NestJS
NestJS is an opinionated Node.js framework built on top of Express (or Fastify). It adds structure that Express deliberately omits: modules for organizing code, dependency injection for testability, and decorators for declarative route definitions.
| Aspect | Express (lecture 66) | NestJS |
|---|---|---|
| Philosophy | Minimal, DIY | Opinionated, batteries-included |
| Structure | You decide | Modules, controllers, services enforced |
| Dependency injection | Manual (or none) | Built-in, constructor-based |
| Validation | Manual checks (lecture 67) | class-validator decorators |
| TypeScript | Supported, not required | First-class, assumed |
| HTTP adapter | Is the adapter | Express or Fastify underneath |
| Learning curve | Low — fewer concepts | Higher — more patterns to learn |
| Scalability | Up to you | Structure scales with team size |
When to use which: Express for small APIs, prototypes, or when you want maximum control. NestJS for team projects, enterprise apps, or when you want conventions instead of decisions. NestJS uses Express under the hood by default — everything you learned about middleware, req/res, and the HTTP pipeline still applies.
Project Setup
CLI scaffold
NestJS provides a CLI that generates a fully configured TypeScript project:
# Install the NestJS CLI globally
npm install -g @nestjs/cli
# Create a new project
nest new task-api-nest
Choose npm as the package manager when prompted. The CLI generates a working project with TypeScript, testing, linting, and a dev server preconfigured.
cd task-api-nest
Project structure
task-api-nest/
├── src/
│ ├── app.controller.ts # Default controller (remove later)
│ ├── app.controller.spec.ts # Default test
│ ├── app.module.ts # Root module
│ ├── app.service.ts # Default service (remove later)
│ └── main.ts # Entry point — bootstraps the app
├── test/
│ ├── app.e2e-spec.ts # E2E test template
│ └── jest-e2e.json
├── nest-cli.json
├── package.json
├── tsconfig.json
└── tsconfig.build.json
Key differences from the Express project (lecture 66):
- No
"type": "module"— NestJS uses CommonJS by default (the TypeScript compiler handlesimport/export) - No
tsxdev runner — NestJS has its ownstart:devscript with hot reload viats-node+webpack - Decorators everywhere — NestJS relies on TypeScript experimental decorators (
@Controller,@Get,@Injectable)
Running the app
# Development with hot reload
npm run start:dev
# Production build
npm run build
npm run start:prod
The dev server runs on http://localhost:3000 by default. Visit it — you should see "Hello World!" from the generated default controller.
NestJS requires Node.js 20+ (same as our Express setup from lecture 66). The CLI generates a tsconfig.json with experimentalDecorators: true and emitDecoratorMetadata: true — both are required for NestJS decorators to work.
Architecture Concepts
Modules
Every NestJS app has at least one module — the root AppModule. Modules group related functionality: a TasksModule contains the tasks controller, service, and DTOs.
// src/app.module.ts
import { Module } from "@nestjs/common";
@Module({
imports: [], // other modules this module depends on
controllers: [], // controllers that handle HTTP requests
providers: [], // services, repositories, helpers — anything injectable
})
export class AppModule {}
The @Module decorator tells NestJS what this module contains and what it needs. Think of modules as the organizing principle — they replace the flat file structure of the Express project.
Controllers
Controllers handle incoming HTTP requests. Each method is decorated with an HTTP verb and a route path:
// src/app.controller.ts
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
Compare with Express:
| Express (lecture 66) | NestJS |
|---|---|
router.get("/", (req, res) => { ... }) | @Get() method on a class |
req.params.id | @Param("id") id: string |
req.body | @Body() dto: CreateTaskDto |
req.query.status | @Query("status") status: string |
res.status(201).json(data) | Return the object — NestJS serializes and sends it |
Controllers should only handle HTTP concerns — extracting parameters, calling services, returning responses. Business logic belongs in services.
Providers & Services
Services contain business logic. The @Injectable() decorator marks a class as something NestJS can inject:
// src/app.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello(): string {
return "Hello World!";
}
}
Dependency Injection
NestJS automatically creates and injects service instances. When a controller declares a service in its constructor, NestJS looks up the module's providers array, creates an instance (singleton by default), and passes it in:
// Controller asks for AppService — NestJS provides it
constructor(private readonly appService: AppService) {}
This is the same pattern as Angular's DI (lecture 75). In Express, you would import and call the service directly — which works, but makes unit testing harder because you cannot swap the real service for a mock.
The controller handles the HTTP layer. The service handles the logic. NestJS wires them together through dependency injection.
At this point you have a running NestJS app with the default controller and service. npm run start:dev should show "Hello World!" at http://localhost:3000.
DTOs & Validation
In lecture 67, you validated request bodies manually — checking each field, building error arrays, returning 400 responses. NestJS automates this with class-validator decorators on DTO classes.
class-validator decorators
Install the validation packages:
npm install class-validator class-transformer
Define DTOs as classes with validation decorators:
// src/tasks/dto/create-task.dto.ts
import { IsString, IsEnum, IsOptional, IsDateString, MinLength } from "class-validator";
export enum TaskStatus {
TODO = "todo",
IN_PROGRESS = "in_progress",
DONE = "done",
}
export enum TaskPriority {
LOW = "low",
MEDIUM = "medium",
HIGH = "high",
}
export class CreateTaskDto {
@IsString()
@MinLength(1)
title: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@IsOptional()
@IsDateString()
dueDate?: string | null;
@IsOptional()
@IsString()
categoryId?: string | null;
}
// src/tasks/dto/update-task.dto.ts — PUT: all fields required (full replacement)
import { IsString, IsEnum, IsDateString, MinLength, ValidateIf } from "class-validator";
import { TaskStatus, TaskPriority } from "./create-task.dto";
export class UpdateTaskDto {
@IsString()
@MinLength(1)
title: string;
@IsString()
description: string;
@IsEnum(TaskStatus)
status: TaskStatus;
@IsEnum(TaskPriority)
priority: TaskPriority;
@ValidateIf((o) => o.dueDate !== null)
@IsDateString()
dueDate: string | null;
@ValidateIf((o) => o.categoryId !== null)
@IsString()
categoryId: string | null;
}
For PATCH (partial update), we need a separate DTO class where every field is optional but still validated — Partial<UpdateTaskDto> won't work because TypeScript utility types are erased at runtime, so class-validator would see a plain object with no decorators:
// src/tasks/dto/patch-task.dto.ts — PATCH: all fields optional with validation
import { IsString, IsEnum, IsOptional, IsDateString, MinLength, ValidateIf } from "class-validator";
import { TaskStatus, TaskPriority } from "./create-task.dto";
export class PatchTaskDto {
@IsOptional()
@IsString()
@MinLength(1)
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@IsOptional()
@ValidateIf((o) => o.dueDate !== null)
@IsDateString()
dueDate?: string | null;
@IsOptional()
@ValidateIf((o) => o.categoryId !== null)
@IsString()
categoryId?: string | null;
}
Compare this to the 40+ lines of manual validation in lecture 67. The decorators express the same rules declaratively.
ValidationPipe
The ValidationPipe connects class-validator to NestJS. Enable it globally in main.ts:
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties not in the DTO
forbidNonWhitelisted: true, // throw error if unknown properties are sent
transform: true, // auto-transform payloads to DTO instances
}),
);
await app.listen(3000);
}
bootstrap();
When a request body fails validation, NestJS automatically returns a 400 response with structured error messages:
{
"statusCode": 400,
"message": ["title must be longer than or equal to 1 characters", "status must be one of the following values: todo, in_progress, done"],
"error": "Bad Request"
}
Forgetting whitelist: true allows mass assignment. Without it, a client can send { "title": "task", "userId": "admin-id" } and the extra userId field passes through to your service. With whitelist: true, unknown fields are silently stripped. With forbidNonWhitelisted: true, the request is rejected entirely.
class-validator uses decorators and reflect-metadata under the hood. NestJS installs reflect-metadata automatically. The DTOs must be classes, not interfaces or types — decorators only work on classes.
Controllers & Services — CRUD
Tasks module
Generate the module, controller, and service with the CLI:
nest generate module tasks
nest generate controller tasks
nest generate service tasks
This creates:
src/tasks/
├── tasks.module.ts
├── tasks.controller.ts
├── tasks.controller.spec.ts
├── tasks.service.ts
└── tasks.service.spec.ts
The CLI also updates app.module.ts to import TasksModule.
Task interface — define the shape of a task in the system:
// src/tasks/interfaces/task.interface.ts
export interface ITask {
id: string;
title: string;
description: string;
status: string;
priority: string;
dueDate: string | null;
categoryId: string | null;
userId: string;
createdAt: string;
updatedAt: string;
}
Service — business logic with in-memory storage (replaced with Prisma in lecture 71):
// src/tasks/tasks.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { ITask } from "./interfaces/task.interface";
import { CreateTaskDto, TaskStatus, TaskPriority } from "./dto/create-task.dto";
import { UpdateTaskDto } from "./dto/update-task.dto";
import { PatchTaskDto } from "./dto/patch-task.dto";
@Injectable()
export class TasksService {
// In-memory store — replaced with database in lecture 71
private tasks: ITask[] = [];
findAll(status?: string, priority?: string): ITask[] {
let result = [...this.tasks];
if (status) result = result.filter((t) => t.status === status);
if (priority) result = result.filter((t) => t.priority === priority);
return result;
}
findOne(id: string): ITask {
const task = this.tasks.find((t) => t.id === id);
if (!task) throw new NotFoundException("Task not found");
return task;
}
create(dto: CreateTaskDto, userId: string): ITask {
const now = new Date().toISOString();
const task: ITask = {
id: uuidv4(),
title: dto.title,
description: dto.description ?? "",
status: dto.status ?? TaskStatus.TODO,
priority: dto.priority ?? TaskPriority.MEDIUM,
dueDate: dto.dueDate ?? null,
categoryId: dto.categoryId ?? null,
userId,
createdAt: now,
updatedAt: now,
};
this.tasks.push(task);
return task;
}
update(id: string, dto: UpdateTaskDto): ITask {
const index = this.tasks.findIndex((t) => t.id === id);
if (index === -1) throw new NotFoundException("Task not found");
this.tasks[index] = {
...this.tasks[index],
title: dto.title,
description: dto.description,
status: dto.status,
priority: dto.priority,
dueDate: dto.dueDate,
categoryId: dto.categoryId,
updatedAt: new Date().toISOString(),
};
return this.tasks[index];
}
partialUpdate(id: string, dto: PatchTaskDto): ITask {
const index = this.tasks.findIndex((t) => t.id === id);
if (index === -1) throw new NotFoundException("Task not found");
const { ...allowedFields } = dto;
this.tasks[index] = {
...this.tasks[index],
...allowedFields,
updatedAt: new Date().toISOString(),
};
return this.tasks[index];
}
remove(id: string): void {
const index = this.tasks.findIndex((t) => t.id === id);
if (index === -1) throw new NotFoundException("Task not found");
this.tasks.splice(index, 1);
}
}
Install uuid for ID generation:
npm install uuid
npm install -D @types/uuid
Controller — handles HTTP, delegates to the service:
// src/tasks/tasks.controller.ts
import {
Controller, Get, Post, Put, Patch, Delete,
Param, Query, Body, HttpCode, HttpStatus,
} from "@nestjs/common";
import { TasksService } from "./tasks.service";
import { CreateTaskDto } from "./dto/create-task.dto";
import { UpdateTaskDto } from "./dto/update-task.dto";
import { PatchTaskDto } from "./dto/patch-task.dto";
@Controller("api/v1/tasks")
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(@Query("status") status?: string, @Query("priority") priority?: string) {
return this.tasksService.findAll(status, priority);
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.tasksService.findOne(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateTaskDto) {
// "temp-user" replaced with real user after auth (lecture 72)
return this.tasksService.create(dto, "temp-user");
}
@Put(":id")
update(@Param("id") id: string, @Body() dto: UpdateTaskDto) {
return this.tasksService.update(id, dto);
}
@Patch(":id")
partialUpdate(@Param("id") id: string, @Body() dto: PatchTaskDto) {
return this.tasksService.partialUpdate(id, dto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id") id: string) {
this.tasksService.remove(id);
}
}
Compare this with the Express router from lecture 67. The same endpoints exist, but:
- Route paths are declarative (
@Controller("api/v1/tasks")+@Get(":id")) - Validation is automatic (the
ValidationPipeprocesses@Body() dto) - Error handling is built-in (
NotFoundExceptionbecomes a404response automatically) - No
req,resobjects — NestJS handles serialization
Module — ties controller and service together:
// src/tasks/tasks.module.ts
import { Module } from "@nestjs/common";
import { TasksController } from "./tasks.controller";
import { TasksService } from "./tasks.service";
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
Categories module
Same pattern, shorter. Generate and implement:
nest generate module categories
nest generate controller categories
nest generate service categories
// src/categories/dto/create-category.dto.ts
import { IsString, MinLength } from "class-validator";
export class CreateCategoryDto {
@IsString()
@MinLength(1)
name: string;
}
// src/categories/categories.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { CreateCategoryDto } from "./dto/create-category.dto";
export interface ICategory {
id: string;
name: string;
userId: string;
createdAt: string;
}
@Injectable()
export class CategoriesService {
private categories: ICategory[] = [];
findAll(): ICategory[] {
return [...this.categories];
}
findOne(id: string): ICategory {
const cat = this.categories.find((c) => c.id === id);
if (!cat) throw new NotFoundException("Category not found");
return cat;
}
create(dto: CreateCategoryDto, userId: string): ICategory {
const category: ICategory = {
id: uuidv4(),
name: dto.name.trim(),
userId,
createdAt: new Date().toISOString(),
};
this.categories.push(category);
return category;
}
update(id: string, dto: CreateCategoryDto): ICategory {
const index = this.categories.findIndex((c) => c.id === id);
if (index === -1) throw new NotFoundException("Category not found");
this.categories[index] = { ...this.categories[index], name: dto.name.trim() };
return this.categories[index];
}
remove(id: string): void {
const index = this.categories.findIndex((c) => c.id === id);
if (index === -1) throw new NotFoundException("Category not found");
this.categories.splice(index, 1);
}
}
// src/categories/categories.controller.ts
import {
Controller, Get, Post, Put, Delete,
Param, Body, HttpCode, HttpStatus,
} from "@nestjs/common";
import { CategoriesService } from "./categories.service";
import { CreateCategoryDto } from "./dto/create-category.dto";
@Controller("api/v1/categories")
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
findAll() {
return this.categoriesService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.categoriesService.findOne(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateCategoryDto) {
return this.categoriesService.create(dto, "temp-user");
}
@Put(":id")
update(@Param("id") id: string, @Body() dto: CreateCategoryDto) {
return this.categoriesService.update(id, dto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id") id: string) {
this.categoriesService.remove(id);
}
}
Root module — import both feature modules:
// src/app.module.ts
import { Module } from "@nestjs/common";
import { TasksModule } from "./tasks/tasks.module";
import { CategoriesModule } from "./categories/categories.module";
@Module({
imports: [TasksModule, CategoriesModule],
})
export class AppModule {}
Test with curl:
# Create a task
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn NestJS"}'
# List tasks
curl http://localhost:3000/api/v1/tasks
# Create with invalid data — should return 400
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}'
All task and category CRUD endpoints work. Validation rejects invalid requests automatically. In-memory storage resets on restart — the database in lecture 71 makes it persistent.
Exception Handling
Built-in exceptions
NestJS provides HTTP exception classes that automatically generate the correct status code and response body:
import {
NotFoundException, // 404
BadRequestException, // 400
UnauthorizedException, // 401
ForbiddenException, // 403
ConflictException, // 409
} from "@nestjs/common";
// Usage — throws become HTTP responses automatically
throw new NotFoundException("Task not found");
// → { "statusCode": 404, "message": "Task not found", "error": "Not Found" }
throw new BadRequestException(["field1 is required", "field2 must be a string"]);
// → { "statusCode": 400, "message": ["field1 is required", "field2 must be a string"], "error": "Bad Request" }
Compare with Express, where you manually called res.status(404).json({ error: "Task not found" }). In NestJS, the exception layer converts thrown exceptions to HTTP responses.
Global exception filter
NestJS has a built-in exception filter that handles all HttpException subclasses. For unhandled errors (bugs, database failures), you can add a global catch-all:
// src/common/filters/all-exceptions.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common";
import { Request, Response } from "express";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: "Internal server error";
// Log unexpected errors (not HttpExceptions — those are intentional)
if (!(exception instanceof HttpException)) {
console.error("Unhandled exception:", exception);
}
response.status(status).json({
statusCode: status,
message: typeof message === "string" ? message : (message as any).message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Register it in main.ts:
// src/main.ts — add after ValidationPipe setup
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
// inside bootstrap():
app.useGlobalFilters(new AllExceptionsFilter());
Catching HttpException and re-throwing a generic 500. The filter above checks instanceof HttpException first — intentional exceptions (404, 400, 401) keep their status codes. Only truly unexpected errors become 500s. If you catch everything as 500, you lose the useful error responses from NotFoundException and ValidationPipe.
Pipes, Guards, Interceptors
NestJS has a layered request lifecycle. Understanding when each layer runs helps you choose where to put logic:
| Layer | Purpose | Runs when | Example |
|---|---|---|---|
| Middleware | Low-level request processing | Before everything | Logging, CORS, body parsing |
| Guard | Authorization decisions | Before pipes and handler | JwtAuthGuard — check if user is authenticated |
| Interceptor | Cross-cutting concerns | Before and after handler | Response transformation, logging, caching |
| Pipe | Data transformation and validation | Before handler, on parameters | ValidationPipe, ParseIntPipe, ParseUUIDPipe |
| Exception filter | Error handling | When exceptions are thrown | Convert exceptions to HTTP responses |
Built-in pipes
// ParseUUIDPipe — validate and transform UUID path parameters
@Get(":id")
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.tasksService.findOne(id);
}
// If id is not a valid UUID → 400 Bad Request automatically
Guards
Guards determine whether a request should be handled. They are the right place for authentication and authorization. We implement JwtAuthGuard in lecture 72 — for now, understand that guards run before the handler and can reject the request:
// Preview — full implementation in lecture 72
@UseGuards(JwtAuthGuard)
@Controller("api/v1/tasks")
export class TasksController { ... }
Interceptors
Interceptors wrap the handler execution. They can transform the response, add headers, log timing, or implement caching:
// src/common/interceptors/logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
import { Observable, tap } from "rxjs";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const now = Date.now();
return next.handle().pipe(
tap(() => {
console.log(`${req.method} ${req.url} — ${Date.now() - now}ms`);
}),
);
}
}
You do not need to use interceptors for basic CRUD. They become useful for cross-cutting concerns like logging, response serialization, or caching. The ValidationPipe (already configured globally) handles the most common pipe use case.
Configuration & CORS
ConfigModule
Install the config package:
npm install @nestjs/config
Create a .env file:
# .env
PORT=3000
CORS_ORIGIN=http://localhost:5173,http://localhost:3001
Import ConfigModule in the root module:
// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TasksModule } from "./tasks/tasks.module";
import { CategoriesModule } from "./categories/categories.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // loads .env, available everywhere
TasksModule,
CategoriesModule,
],
})
export class AppModule {}
Use ConfigService to read environment variables:
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AppModule } from "./app.module";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new AllExceptionsFilter());
// CORS — same concept as lecture 66, NestJS API
const origins = configService.get<string>("CORS_ORIGIN")?.split(",") || ["http://localhost:5173"];
app.enableCors({
origin: origins,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
});
const port = parseInt(configService.get<string>("PORT") || "3000", 10);
await app.listen(port);
console.log(`Server running on http://localhost:${port}`);
}
bootstrap();
Add .env to .gitignore (same as lecture 65). Commit a .env.example with placeholder values. ConfigModule.forRoot() reads .env automatically using dotenv under the hood — no need to install dotenv separately.
CORS
The app.enableCors() call above is the NestJS equivalent of the cors middleware from lecture 66. The same rules apply:
- Specify exact origins (no
*withcredentials: true) - Include
AuthorizationinallowedHeadersfor JWT auth - The browser sends preflight
OPTIONSrequests for non-simple methods
See lecture 66 for the full CORS explanation — the concept is identical, only the API differs.
NestJS app with validated DTOs, global exception handling, CORS, and environment configuration. All CRUD endpoints work with in-memory storage. The next lecture (71) replaces the arrays with Prisma + PostgreSQL.
Defence Preparation
Be prepared to explain topics like these:
-
What is dependency injection and why does NestJS use it? — Dependency injection means a class declares what it needs (via constructor parameters), and the framework provides those dependencies. NestJS creates singleton instances of services and passes them to controllers automatically. The benefit is testability: in unit tests, you can provide a mock service instead of the real one. Without DI, the controller would import and call the service directly, making it impossible to swap for testing. DI also enforces loose coupling — the controller does not know how the service is implemented, only what methods it exposes.
-
What is the difference between a controller and a service in NestJS? — Controllers handle the HTTP layer: they extract parameters from the request, call service methods, and return responses. Services contain the business logic: data manipulation, database queries, validation rules. This separation follows the Single Responsibility Principle — if you want to reuse the same logic in a CLI tool or a scheduled job, you call the service directly without the HTTP layer. NestJS enforces this pattern through its module system: controllers go in the
controllersarray, services go inproviders. -
How does NestJS validation with class-validator work? — You define DTO classes with validation decorators (
@IsString(),@IsEnum(),@MinLength()). The globalValidationPipeintercepts incoming requests, creates an instance of the DTO class from the request body, and runs the validation decorators. If any validation fails, it throws aBadRequestExceptionwith the error messages before the handler runs. Thewhitelist: trueoption strips unknown fields, andforbidNonWhitelisted: truerejects requests with unknown fields entirely, preventing mass assignment attacks. -
What is the NestJS request lifecycle? — A request passes through these layers in order: (1) Middleware — low-level processing like body parsing and logging. (2) Guards — authorization checks, can reject the request. (3) Interceptors (pre-handler) — can transform the request or short-circuit. (4) Pipes — validate and transform parameters and body. (5) Route handler — your controller method. (6) Interceptors (post-handler) — can transform the response. (7) Exception filters — catch thrown exceptions and convert them to HTTP responses. Understanding this order matters because it determines where you put logic — auth goes in guards (runs before validation), validation goes in pipes, response shaping goes in interceptors.
-
When would you use a guard vs an interceptor vs a pipe? — Guards make yes/no authorization decisions: "Is this user allowed to access this endpoint?" They run before pipes and handlers. Pipes transform and validate data: "Is this body a valid CreateTaskDto?" "Is this parameter a valid UUID?" Interceptors handle cross-cutting concerns that wrap the handler: logging request duration, caching responses, transforming response shapes. The key distinction: guards block or allow, pipes validate input, interceptors modify behaviour around the handler.
-
How does NestJS use Express under the hood? — NestJS wraps Express (or Fastify) as an HTTP adapter. When you create a NestJS app, it creates an Express instance internally. Your decorators (
@Get,@Post,@Body) are translated to Express route registrations. Middleware registered with NestJS is passed to Express's middleware pipeline. You can access the underlying ExpressRequestandResponseobjects with@Req()and@Res()decorators if needed — but doing so bypasses NestJS's response handling, which is why you should avoid it in most cases. -
What does
forbidNonWhitelisted: truedo and why is it important? — Without it,whitelist: truesilently strips unknown fields from the request body — the request succeeds, and extra fields are ignored. WithforbidNonWhitelisted: true, any unknown field causes a400 Bad Request. This matters for security (mass assignment: a client sending{ "role": "admin" }alongside valid fields) and for developer experience (catching typos in field names during development — if you sendtittleinstead oftitle, you get an error instead of silent success with a missing title).