📋 Today's Case Study

DevTask API — A Developer Team Task Management System

You are a backend engineer at a growing tech startup. The CTO has asked you to build an internal REST API that engineering teams can use to track tasks, manage users, and monitor project progress. You will build this system from scratch today, adding a new layer with every NestJS concept you learn.

✅ Tasks ModuleFull CRUD — create, list, update, delete tasks
✅ DTOs & ValidationType-safe input with class-validator
✅ Error HandlingCustom exception filters
✅ Auth GuardAPI key-based route protection
✅ LoggingRequest/response interceptor
✅ PipesValidation and transformation pipeline

📅 Day Schedule

09:00
Module 1Intro, Setup & Project Structure
10:00
Module 2Controllers & Routing
11:00
☕ Break (15 min)
11:15
Module 3Providers & Dependency Injection
12:15
Module 4Modules & Application Structure
13:00
🍽 Lunch (45 min)
13:45
Module 5DTOs, Pipes & Validation
14:45
Module 6Exception Filters
15:30
☕ Break (15 min)
15:45
Module 7Guards
16:30
Module 8Interceptors & Middleware
17:15
Wrap-UpFinal challenge & Q&A
1

Introduction, Setup & Project Structure

60 min

What is NestJS?

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.

Why NestJS over plain Express? Express gives you routing. NestJS gives you architecture — a consistent way to organise code that scales across teams and months of development.

Core Building Blocks

Request │ ▼ Middleware ──► Guards ──► Interceptors ──► Pipes │ ▼ Controller ──► Service ──► Repository │ ▼ Interceptors (response) │ ▼ Exception Filters (on error)
Building BlockResponsibilityDecorator
ControllerHandle HTTP requests, define routes@Controller()
Provider / ServiceBusiness logic, injectable dependencies@Injectable()
ModuleOrganise and group related code@Module()
MiddlewarePre-processing of requestsClass or function
GuardAuthorization — can this request proceed?@UseGuards()
PipeValidation and transformation of input@UsePipes()
InterceptorWrap request/response logic@UseInterceptors()
Exception FilterHandle and transform errors@UseFilters()

Project File Structure

DevTask API — target structure
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
1
Install the NestJS CLI globally
npm install -g @nestjs/cli
nest --version   # should print 10.x.x
2
Create the project
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!

3
Explore the generated files

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 route
4
Scaffold the Tasks feature module
nest generate module tasks
nest generate controller tasks
nest generate service tasks

Check app.module.ts — NestJS automatically imported TasksModule.

5
Create the Task entity interface

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;
}
Question 1

Which NestJS building block is responsible for authorization — deciding whether a request should proceed?

  • Middleware
  • Interceptor
  • Guard
  • Pipe
Show answer
✅ C — Guard. Guards implement the CanActivate interface and return true/false to allow or reject a request before it reaches the controller.
Question 2

In the NestJS request lifecycle, what runs before Guards?

  • Pipes
  • Middleware
  • Interceptors
  • Exception Filters
Show answer
✅ B — Middleware. The order is: Middleware → Guards → Interceptors → Pipes → Controller → Interceptors (response) → Exception Filters (on error).
Question 3

What does nest generate module tasks automatically do to app.module.ts?

Show answer
✅ It imports TasksModule into the imports array of AppModule automatically — no manual wiring needed.
2

Controllers & Routing

60 min

What is a Controller?

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.

Key Route Decorators

DecoratorHTTP MethodUsage
@Get(path?)GETRetrieve resources
@Post(path?)POSTCreate resources
@Put(path?)PUTReplace a resource
@Patch(path?)PATCHPartial update
@Delete(path?)DELETERemove a resource

Parameter Decorators

DecoratorExtracts
@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

HTTP Status Codes

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> { ... }
1
Update TasksController with all CRUD routes src/tasks/tasks.controller.ts
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);
  }
}
2
Test with curl or Postman
# 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"
The service methods don't exist yet — you'll implement them in Module 3. The server will error until then.
Question 1

You want to extract ?assignee=john from the URL in a controller method. Which decorator do you use?

  • @Param('assignee')
  • @Query('assignee')
  • @Body('assignee')
  • @Headers('assignee')
Show answer
✅ B — @Query('assignee') extracts from the query string.
Question 2

A DELETE /tasks/:id endpoint should return no body. What status code and decorator should you use?

Show answer
@HttpCode(HttpStatus.NO_CONTENT) — returns HTTP 204. NestJS would default to 200 without this.
Question 3

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;
}
Show answer
✅ Two problems: (1) SQL injection vulnerability — never interpolate user input into queries. (2) Business logic (database querying) should be in a service, not a controller.
Challenge

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?

Hint: Route order matters in NestJS. /tasks/stats placed after /tasks/:id would match :id = "stats" instead of the stats route. Place specific routes before parameterised ones.
☕ Morning Break — 15 minutes
3

Providers & Dependency Injection

60 min

What is a Provider?

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.

DI in one sentence: Instead of a class creating its own dependencies with new, it declares what it needs and the framework provides it — making code testable and decoupled.

Provider Scopes

ScopeBehaviourUse Case
DEFAULT (Singleton)One instance per applicationServices, repositories (default)
REQUESTNew instance per requestRequest-scoped state
TRANSIENTNew instance per consumerLightweight, stateless utilities

Custom Providers

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 }
1
Build the in-memory TasksService src/tasks/tasks.service.ts
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);
  }
}
2
Install uuid
npm install uuid
npm install -D @types/uuid
3
Run a full CRUD test
# 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>
Question 1

What happens if you remove @Injectable() from TasksService?

  • The service still works because TypeScript handles injection
  • NestJS throws a runtime error when resolving dependencies
  • The service is instantiated as a singleton without DI
  • Nothing — @Injectable() is optional
Show answer
✅ B — NestJS cannot resolve the dependency and throws a runtime error at bootstrap.
Question 2

In TasksService.findOne() we throw NotFoundException. Where does this get caught and converted to a 404 HTTP response?

Show answer
✅ NestJS has a built-in global exception filter that catches all HttpException subclasses (including NotFoundException) and maps them to the correct HTTP status code and response body automatically.
Question 3

If TasksService is DEFAULT scope (singleton), and 1,000 concurrent requests hit your API, how many instances of TasksService exist?

Show answer
✅ One — singleton scope means a single shared instance handles all requests. This is why storing request-specific state in a service property is dangerous.
4

Modules & Application Architecture

45 min

What is a Module?

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
})
Rule of thumb: If a service is only used inside one module, keep it private (don't export it). Only export what other modules genuinely need.

Shared Modules vs Global Modules

Mark a module @Global() to make its exports available everywhere without importing it — useful for logging, config, or database connections. Use sparingly.

1
Generate Users module
nest generate module users
nest generate controller users
nest generate service users
2
Create a basic UsersService that TasksService can reference src/users/users.service.ts
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);
  }
}
3
Export UsersService from UsersModule so TasksModule can use it src/users/users.module.ts
@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],   // ← makes UsersService available to other modules
})
export class UsersModule {}
4
Import UsersModule into TasksModule src/tasks/tasks.module.ts
@Module({
  imports: [UsersModule],   // ← now TasksService can inject UsersService
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}
Question 1

You add UsersService to TasksModule.providers instead of importing UsersModule. What is the problem?

Show answer
✅ You now have two separate instances of UsersService — one in UsersModule and one in TasksModule. They don't share state. Always import the module; never re-declare providers that belong to another module.
Question 2

When should you use @Global()?

Show answer
✅ Sparingly — only for truly cross-cutting infrastructure like database connections, configuration service, or a logger that every module needs. Feature-level services should never be global.
🍽 Lunch Break — 45 minutes
5

DTOs, Pipes & Validation

60 min

What is a DTO?

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.

What is a Pipe?

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 PipePurpose
ValidationPipeValidates incoming data against a DTO using class-validator
ParseIntPipeConverts string route param to integer
ParseUUIDPipeValidates that a param is a valid UUID
ParseEnumPipeValidates value is a member of an enum
DefaultValuePipeProvides a default when value is undefined

Pipe Binding Levels

// 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
1
Install validation dependencies
npm install class-validator class-transformer
2
Create CreateTaskDto src/tasks/dto/create-task.dto.ts
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;
}
3
Create UpdateTaskStatusDto src/tasks/dto/update-task-status.dto.ts
import { IsEnum } from 'class-validator';
import { TaskStatus } from '../entities/task.entity';

export class UpdateTaskStatusDto {
  @IsEnum(TaskStatus)
  status: TaskStatus;
}
4
Enable global ValidationPipe in main.ts src/main.ts
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();
5
Update controller to use DTOs
@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);
}
6
Test validation errors
# 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}'
Question 1

What does whitelist: true on ValidationPipe do?

  • Allows only whitelisted IP addresses
  • Strips properties from the body that are not in the DTO
  • Logs valid requests to a whitelist file
  • Disables validation for known-good inputs
Show answer
✅ B — whitelist: true removes any properties from the request body that don't have a corresponding decorator in the DTO, protecting against mass assignment attacks.
Question 2

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?

Show answer
✅ Use @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.
Question 3

Why is transform: true useful when your route param :page is declared as page: number in the method signature?

Show answer
✅ HTTP route params are always strings. With transform: true, ValidationPipe automatically converts "3" to 3 based on the TypeScript type annotation — without needing ParseIntPipe.
Custom Pipe Challenge

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
  }
}
6

Exception Filters

45 min

Built-in HTTP Exceptions

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 ClassStatus
BadRequestException400
UnauthorizedException401
ForbiddenException403
NotFoundException404
ConflictException409
UnprocessableEntityException422
InternalServerErrorException500

Custom Exception Filters

When you need a consistent error envelope, custom error codes, or to log errors before responding, implement a custom ExceptionFilter.

1
Create the filter src/common/filters/http-exception.filter.ts
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);
  }
}
2
Register globally in main.ts
app.useGlobalFilters(new HttpExceptionFilter());
3
Test error responses
# 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"
}
Question 1

An unhandled TypeError: Cannot read properties of undefined is thrown in a service. Which filter catches it?

  • Your @Catch(HttpException) filter
  • NestJS built-in global exception filter
  • No filter — the process crashes
  • ValidationPipe
Show answer
✅ B — NestJS has a catch-all built-in filter that handles any unrecognised exception and returns a 500. Your custom filter only catches HttpException. To catch everything, use @Catch() with no arguments.
Question 2

How do you create a domain-specific exception that always returns 409 Conflict?

Show answer
✅ Extend ConflictException or HttpException: export class TaskAlreadyExistsException extends ConflictException { constructor(title: string) { super(\`Task "${title}" already exists\`); } }
☕ Afternoon Break — 15 minutes
7

Guards

45 min

What is a Guard?

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.

ExecutionContext

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

Reflector & Custom Metadata

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());
1
Create the guard src/common/guards/api-key.guard.ts
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;
  }
}
2
Apply guard to the TasksController
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 { ... }
3
Test with and without the header
# 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!"}'
Question 1

You apply @UseGuards(ApiKeyGuard) at the controller level but want one specific route to be public. What is the cleanest approach?

Show answer
✅ Create a custom @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.
Question 2

What is the difference between returning false and throwing UnauthorizedException in a guard?

Show answer
✅ Returning 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.
Role-Based Guard

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.

8

Interceptors & Middleware

45 min

What is an Interceptor?

Interceptors wrap the request/response cycle using RxJS Observables. They can:

  • Run logic before the route handler (e.g., start a timer)
  • Modify or replace the response (e.g., wrap in a standard envelope)
  • Handle exceptions from the route
  • Cache responses
@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 */),
      );
  }
}

Middleware vs Interceptors vs Guards

FeatureMiddlewareGuardInterceptor
Runs before handler
Access to response
Can modify response
Can abort request
NestJS DI support
Typical useLogging, CORS, body-parseAuth, rolesLogging, caching, transform
1
Create a logging interceptor src/common/interceptors/logging.interceptor.ts
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`);
      }),
    );
  }
}
2
Create a response transform interceptor src/common/interceptors/transform.interceptor.ts
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(),
      })),
    );
  }
}
3
Register both globally src/main.ts
app.useGlobalInterceptors(
  new LoggingInterceptor(),
  new TransformInterceptor(),
);
4
Test the response envelope
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"
}
Question 1

Your TransformInterceptor wraps all responses in { success: true, data: ... }. But your HttpExceptionFilter returns { statusCode, message, ... } directly. Will exceptions be double-wrapped?

Show answer
✅ No — exception filters run after interceptors. When an exception is thrown, the interceptor's next.handle() observable errors out, and the exception filter takes over directly, bypassing the interceptor's map() operator.
Question 2

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?

Show answer
✅ Interceptor — it has access to the response object and can be scoped to a specific route or controller method. Middleware would require URL-based conditional logic and can't cleanly target a single NestJS route.
Timeout Interceptor

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);
  }),
);
🏆

Final Challenge — Complete the DevTask API

30 min

You have all the building blocks. Complete the DevTask API by combining everything you've learned:

Challenge 1 — Task Assignment

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.

Challenge 2 — Config-driven API Key

Move the API key from a hardcoded string to an environment variable using NestJS ConfigModule. Install @nestjs/config and inject ConfigService into your guard.

Challenge 3 — Pagination

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.

Challenge 4 — Audit Interceptor

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

📚 Quick Reference — NestJS CLI Commands

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