Skip to main content

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:

  1. Project setup — CLI scaffold, project structure
  2. Architecture — modules, controllers, services, dependency injection
  3. DTOs & validation — class-validator, ValidationPipe
  4. CRUD — tasks and categories with in-memory storage
  5. Exception handling — built-in exceptions, global filter
  6. Middleware lifecycle — pipes, guards, interceptors
  7. 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.

AspectExpress (lecture 66)NestJS
PhilosophyMinimal, DIYOpinionated, batteries-included
StructureYou decideModules, controllers, services enforced
Dependency injectionManual (or none)Built-in, constructor-based
ValidationManual checks (lecture 67)class-validator decorators
TypeScriptSupported, not requiredFirst-class, assumed
HTTP adapterIs the adapterExpress or Fastify underneath
Learning curveLow — fewer conceptsHigher — more patterns to learn
ScalabilityUp to youStructure 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 handles import/export)
  • No tsx dev runner — NestJS has its own start:dev script with hot reload via ts-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.

info

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
info

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.

Checkpoint 1 — hello world

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"
}
Common mistake

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.

info

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 ValidationPipe processes @Body() dto)
  • Error handling is built-in (NotFoundException becomes a 404 response automatically)
  • No req, res objects — 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": ""}'
Checkpoint 2 — in-memory CRUD

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());
Common mistake

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:

LayerPurposeRuns whenExample
MiddlewareLow-level request processingBefore everythingLogging, CORS, body parsing
GuardAuthorization decisionsBefore pipes and handlerJwtAuthGuard — check if user is authenticated
InterceptorCross-cutting concernsBefore and after handlerResponse transformation, logging, caching
PipeData transformation and validationBefore handler, on parametersValidationPipe, ParseIntPipe, ParseUUIDPipe
Exception filterError handlingWhen exceptions are thrownConvert 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`);
}),
);
}
}
info

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();
warning

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 * with credentials: true)
  • Include Authorization in allowedHeaders for JWT auth
  • The browser sends preflight OPTIONS requests for non-simple methods

See lecture 66 for the full CORS explanation — the concept is identical, only the API differs.

Checkpoint 3 — production-ready scaffold

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:

  1. 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.

  2. 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 controllers array, services go in providers.

  3. How does NestJS validation with class-validator work? — You define DTO classes with validation decorators (@IsString(), @IsEnum(), @MinLength()). The global ValidationPipe intercepts incoming requests, creates an instance of the DTO class from the request body, and runs the validation decorators. If any validation fails, it throws a BadRequestException with the error messages before the handler runs. The whitelist: true option strips unknown fields, and forbidNonWhitelisted: true rejects requests with unknown fields entirely, preventing mass assignment attacks.

  4. 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.

  5. 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.

  6. 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 Express Request and Response objects 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.

  7. What does forbidNonWhitelisted: true do and why is it important? — Without it, whitelist: true silently strips unknown fields from the request body — the request succeeds, and extra fields are ignored. With forbidNonWhitelisted: true, any unknown field causes a 400 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 send tittle instead of title, you get an error instead of silent success with a missing title).