Skip to main content

Chapter 3: Controllers & Routing

Controllers are the entry point for handling client requests in NestJS. Think of them as the “reception desk” of your application—they receive requests, ask the right service for help, and send back a response. This chapter explores routing, request/response handling, decorators, middleware, guards, interceptors, error handling, and best practices for building robust APIs.

3.1 What is a Controller?

A controller is responsible for receiving incoming requests, delegating work to services, and returning responses. Controllers define the routes and HTTP methods for your API.

Controller Responsibilities

Controllers handle HTTP-specific concerns:
  • Route definition: Map URLs to handler methods
  • Request extraction: Get data from params, query, body, headers
  • Response formatting: Return data in the correct format
  • Error handling: Throw appropriate HTTP exceptions
  • Validation: Ensure incoming data is valid (via DTOs and pipes)
What Controllers Should NOT Do:
  • Business logic (delegate to services)
  • Database access (delegate to repositories/services)
  • Complex data transformation (delegate to services)
  • Authentication logic (use guards)
Analogy:
Think of a controller as a call center operator. When a customer calls (HTTP request arrives), the operator does not fix the product themselves. They listen to the request, look up the customer’s account (extract params, query, body), route the call to the right department (delegate to a service), and then relay the department’s answer back to the customer (send the response). If the operator starts doing repair work on the phone, the whole system breaks down. That is exactly what happens when you put business logic in controllers — it becomes untestable, unreusable, and tangled with HTTP concerns.

Basic Controller Structure

Key Components:
  • @Controller('users') - Defines the base route for all methods
  • Constructor injection - Services are injected via constructor
  • Route decorators - @Get(), @Post(), etc. define HTTP methods
  • Handler methods - Process requests and return responses
Diagram: Request Flow

3.2 Routing in NestJS

Routes are defined using decorators like @Get, @Post, @Put, @Delete, @Patch, etc. The path can include parameters, query strings, and wildcards.

HTTP Method Decorators

NestJS provides decorators for all HTTP methods:

Route Parameters

Extract dynamic segments from the URL:
Multiple Parameters:
All Parameters:
With Pipes for Validation:

Query Parameters

Extract data from the query string:
Multiple Query Parameters:
All Query Parameters:
With DTO for Validation:

Request Body

Extract data from the request body:
Partial Body:
Multiple Body Properties:

Headers

Extract data from request headers:
All Headers:

Request Object

Access the full request object when needed:

Response Object

Control the response directly:
Warning: Using @Res() bypasses NestJS’s built-in response handling entirely. This means interceptors will not transform your response, exception filters may not catch errors correctly, and NestJS cannot automatically serialize your return value to JSON. Only use @Res() when you need low-level control (like streaming files or setting custom cookies). For everything else, return data from the handler and let NestJS serialize it.

Status Codes

Set custom HTTP status codes:
Common Status Codes:

Route Wildcards

Use wildcards for flexible routing:

3.3 Request Lifecycle

Understanding the request lifecycle helps you know where to place your logic and how different components interact.

Complete Request Lifecycle

This is one of the most important diagrams in the entire course. Memorize this order, because when something goes wrong in production, knowing exactly where in this pipeline your code runs is the difference between a 5-minute fix and a 5-hour debugging session.
  1. Incoming Request - HTTP request arrives at the server
  2. Middleware - Global and route-specific middleware runs (think Express middleware: logging, CORS, body parsing)
  3. Guards - Authentication and authorization checks (“Should this request proceed at all?”)
  4. Interceptors (Before) - Pre-processing (logging, request timing, cache lookup)
  5. Pipes - Validation and transformation of input data (DTOs validated here)
  6. Controller - Route handler method executes (thin — just delegates to service)
  7. Service - Business logic executes (the real work happens here)
  8. Interceptors (After) - Post-processing (response wrapping, cache storage)
  9. Exception Filters - Handle any exceptions thrown during the pipeline (format error responses)
  10. Response - HTTP response sent to client
Diagram: Full Request Lifecycle

Execution Order Example

Middleware vs Guards vs Interceptors vs Pipes — The Definitive Comparison

This is the table you will refer back to most often. These four concepts are the most commonly confused parts of NestJS, and getting them wrong means putting code in the wrong layer. Decision Framework — Where Does My Cross-Cutting Logic Go?
Common Mistake: Using middleware for authentication when you need route metadata (like roles). Middleware cannot read @SetMetadata() decorators because it runs before NestJS resolves the route handler. If you need to check roles or permissions, use a guard.

3.4 Validation & DTOs

Data Transfer Objects (DTOs) define the shape of data for requests and responses. Combined with validation, they ensure your API only accepts well-formed requests.

Why Use DTOs?

  • Type Safety: TypeScript knows the shape of your data
  • Validation: Ensure data meets requirements
  • Documentation: DTOs document your API contract
  • Transformation: Can transform data automatically
  • Security: Prevent invalid or malicious data

Basic DTO

DTO with Validation

Install validation packages:

Common Validation Decorators

Global Validation Pipe

Apply validation globally in main.ts:

Custom Validation

Create custom validators:
Tip: Always validate input to prevent security issues and bugs. Validation should happen at the controller level using DTOs and pipes.

3.5 Middleware

Middleware is executed before the route handler. Use it for logging, authentication, request transformation, CORS, and other cross-cutting concerns.

What is Middleware?

Middleware functions have access to:
  • Request object
  • Response object
  • Next function (to pass control to next middleware)
They can:
  • Execute code before/after the route handler
  • Modify request/response objects
  • End the request-response cycle
  • Call the next middleware in the stack

Functional Middleware

Simple middleware as a function:

Class-Based Middleware

More powerful, can inject dependencies:

Middleware with Dependencies

Registering Middleware

Register in module using configure method:

Route-Specific Middleware

Excluding Routes

Multiple Middleware

Global Middleware

Apply middleware globally in main.ts:
Tip: Use middleware for cross-cutting concerns that apply to many routes. For route-specific logic, use guards or interceptors.

3.6 Guards

Guards determine whether a request should be handled by the route. They run after middleware but before the route handler. Use them for authentication and authorization.

What are Guards?

Guards are like bouncers at a club. Their single job is to decide: “Should this request get in, or should I turn it away?” They run after middleware but before any other processing, which makes them ideal for authentication and authorization. If a guard says no, the request never reaches your controller — saving you from duplicating permission checks in every route handler. Guards return:
  • true - Request proceeds to the next step in the pipeline
  • false - Request is denied (NestJS automatically throws ForbiddenException)

Basic Guard

Using Guards

Controller-level:
Method-level:
Global Guard:

Execution Context

The ExecutionContext provides access to:
  • Request object
  • Response object
  • Next function
  • Handler (controller method)
  • Class (controller class)

Role-Based Guard

Async Guards

Guards can be async:
Tip: Use guards for permission checks, roles, and authentication logic. They’re perfect for protecting routes and controlling access.

3.7 Interceptors

Interceptors can transform the result returned from a function, extend basic method behavior, or handle cross-cutting concerns like logging, caching, or response shaping.

What are Interceptors?

Interceptors are like wrapping paper around a gift. They wrap your route handler, letting you run code before and after execution. Unlike middleware (which only sees the raw request/response), interceptors can see and transform the handler’s return value using RxJS observables. This makes them powerful for cross-cutting concerns that need to act on the result. Think of the logging interceptor pattern: “Start timer, let the handler run, stop timer, log the duration.” Middleware cannot do this because it does not know when the handler finishes — interceptors can, because they wrap the handler in an Observable pipeline. Interceptors have access to:
  • Execution context (which controller, which method, HTTP or WebSocket, etc.)
  • Call handler (to invoke the route handler and get an Observable of the result)
  • Full RxJS operator chain (map, tap, catchError, timeout, etc.)
They can:
  • Execute code before/after method execution (timing, logging)
  • Transform the result (wrap in { data: ..., timestamp: ... } envelope)
  • Transform exceptions (normalize error formats)
  • Short-circuit the handler entirely (caching — return cached data without calling the handler)

Basic Interceptor

Response Transformation Interceptor

Wrap responses in a consistent format:

Error Transformation Interceptor

Transform errors into consistent format:

Caching Interceptor

Cache responses. This is a simplified in-memory example to illustrate the pattern — in production, use Redis or NestJS’s built-in @nestjs/cache-manager module for distributed caching.

Timeout Interceptor

Add timeout to requests:

Using Interceptors

Controller-level:
Method-level:
Global Interceptor:
Tip: Use interceptors for logging, response formatting, caching, and error transformation. They’re powerful for cross-cutting concerns.

3.8 Error Handling

NestJS provides built-in exception filters and HTTP exceptions for consistent error handling.

Built-in HTTP Exceptions

Custom Exception Messages

Exception Filters

Create custom exception filters:
Using Exception Filters:
Global Exception Filter:

3.9 Edge Cases in Request Handling

These are situations that catch experienced developers off guard. Edge Case 1: Multiple decorators on the same parameter You can stack pipes on a parameter, but they execute right-to-left (innermost first). @Param('id', ParseIntPipe, CustomValidationPipe) runs ParseIntPipe first, then passes the result to CustomValidationPipe. If ParseIntPipe converts “42” to 42, CustomValidationPipe receives the number 42, not the string “42”. Edge Case 2: @Res() disables automatic response handling The moment you inject @Res() into a handler, NestJS stops managing the response. Your interceptors will not see the return value, and if you forget to call res.json() or res.send(), the request hangs indefinitely. Use @Res({ passthrough: true }) if you need both @Res() access and NestJS’s automatic response handling.
Edge Case 3: Validation errors from nested DTOs If your DTO contains a nested object, class-validator does not validate nested objects by default. You must add @ValidateNested() and @Type(() => NestedDto) from class-transformer to trigger recursive validation.
Edge Case 4: Guard ordering with multiple @UseGuards() When you apply multiple guards, they execute left-to-right. If the first guard rejects, subsequent guards never run. This matters for auth flows: put JwtAuthGuard (authentication) before RolesGuard (authorization), because checking roles without a valid user makes no sense.
Edge Case 5: File upload with @Body() and @UploadedFile() When handling multipart form data (file uploads), @Body() contains the text fields and @UploadedFile() contains the file. But class-validator cannot validate @Body() in multipart requests the same way it validates JSON — all values arrive as strings. You need to transform them manually or use a custom pipe.

3.9 Best Practices

Following best practices ensures your controllers are maintainable and scalable.

Keep Controllers Thin

Controllers should only handle HTTP concerns:

Use DTOs for All Input

Always validate input using DTOs:

Organize Routes by Feature

Group related routes in feature modules:

Use Appropriate HTTP Methods

Return Consistent Response Shapes

Use interceptors to format responses consistently:

Handle Errors Gracefully

Use built-in exceptions:

Document Your API

Use Swagger for API documentation:

Use Pipes for Transformation


3.10 Summary

You’ve learned how to build robust, maintainable APIs using controllers and routing: Key Concepts:
  • Controllers: Handle HTTP requests and responses
  • Routing: Define routes using decorators
  • DTOs: Validate and transform input data
  • Middleware: Cross-cutting concerns before route handlers
  • Guards: Authentication and authorization
  • Interceptors: Response transformation and logging
  • Error Handling: Consistent error responses
Best Practices:
  • Keep controllers thin
  • Use DTOs for validation
  • Organize routes by feature
  • Use appropriate HTTP methods
  • Handle errors gracefully
  • Document your API
Next Chapter: Learn about providers, services, repository patterns, and domain-driven design in depth.

Interview Deep-Dive

Strong Answer:
  • Middleware runs first and has access to the raw Express/Fastify req, res, next. It does not know which NestJS route handler will execute. Use it for transport-level concerns: request logging, CORS, body parsing, rate limiting at the IP level. If you need to read NestJS route metadata (like @Roles('admin')), middleware cannot do it — use a guard instead.
  • Guards run second and answer one question: “Should this request proceed?” They have access to the ExecutionContext, which knows the target controller and handler. This lets them read decorator metadata via Reflector. Use guards for authentication (is the JWT valid?) and authorization (does this user have the admin role?). Putting auth logic in middleware means you cannot use @Roles() decorators, and putting it in interceptors means unauthorized requests waste time on pre-processing before being rejected.
  • Pipes run after guards and transform/validate individual parameters. ParseIntPipe converts a string route param to a number. ValidationPipe validates a DTO against class-validator decorators. Putting validation in the service layer means invalid data travels further into your application before being rejected, and error messages are less HTTP-specific.
  • Interceptors wrap the handler and can execute code both before and after. They see the handler’s return value via RxJS operators. Use them for response transformation (wrapping in a standard envelope), timing/logging, caching (short-circuit the handler if cache hit), and error mapping. Putting response transformation in the controller means every controller method has to repeat the wrapping logic.
  • The real-world consequence of putting logic in the wrong layer: I once worked on a project where authentication was in middleware. When we needed to add a @Public() decorator to skip auth on certain routes (like health checks), we could not — middleware does not have access to handler metadata. We had to refactor all auth logic into a guard, which touched every module in the application.
Follow-up: If you apply both a global interceptor and a controller-level interceptor, what is the execution order?Global interceptors run first (outermost), then controller-level, then method-level. On the response side, the order reverses (method-level response transformation runs first, then controller-level, then global). This is the “onion model” — each interceptor wraps the next one. A global logging interceptor would log the total request time including all inner interceptor processing, while a method-level caching interceptor would only cache the result of that specific handler.
Strong Answer:
  • This is a global exception filter problem. NestJS has a built-in exception filter that formats HttpException subclasses into a standard shape by default. But if some routes throw raw errors, return custom objects, or use @Res() to bypass NestJS’s response handling, you get inconsistent formats.
  • The fix: create a global AllExceptionsFilter that catches every exception (use @Catch() with no arguments) and formats it into a consistent shape. Register it in main.ts with app.useGlobalFilters(new AllExceptionsFilter()).
  • The filter should handle three cases: (1) HttpException subclasses — extract the status and message from the exception. (2) Known error types (database errors, validation errors) — map them to appropriate HTTP status codes. (3) Unknown errors — return 500 with a generic message, log the actual error for debugging.
  • The important detail: if you register the filter globally in main.ts using app.useGlobalFilters(), it cannot inject dependencies (no DI). If your filter needs to inject a logger service, register it as a provider in AppModule using APP_FILTER: { provide: APP_FILTER, useClass: AllExceptionsFilter }. This gives you DI support.
  • I also add a correlationId to every error response so the frontend can include it in bug reports, and the backend team can search logs by that ID.
Follow-up: How do exception filters interact with interceptors? If an interceptor has a catchError operator and there is also an exception filter, which one catches the error?Interceptors’ catchError runs first because interceptors wrap the handler. If the interceptor catches the error and returns a new observable (like a fallback value), the exception filter never sees the error. If the interceptor re-throws, the exception filter catches it. This ordering matters for retry logic — an interceptor can retry a failed database call three times, and only if all retries fail does it re-throw, which the exception filter then formats into a user-friendly error response.
Strong Answer:
  • NestJS supports four versioning strategies out of the box: URI (/v1/users), Header (X-API-Version: 1), Media Type (Accept: application/vnd.myapi.v1+json), and Custom. You enable versioning in main.ts: app.enableVersioning({ type: VersioningType.URI }).
  • URI versioning is the most common and the simplest to implement. It is visible in the URL, easy to test with curl, and easy to route in load balancers. The downside is URL proliferation — every version creates a new set of URLs. Use @Controller({ path: 'users', version: '1' }) or @Version('1') on individual methods.
  • Header versioning keeps URLs clean but is harder to test (you need to set headers in every request) and harder to cache (CDNs and proxies do not cache based on headers by default).
  • The real-world trade-off is between maintainability and backward compatibility. At one company, we used URI versioning and maintained v1 and v2 simultaneously for 18 months. The cost was maintaining two sets of controllers, two sets of DTOs, and two sets of tests. The lesson: version at the route level, not the module level. Both v1 and v2 controllers can call the same service — only the request/response shape changes.
  • My recommendation: use URI versioning for external APIs (it is the most widely understood), and do not version internal microservice APIs (just coordinate deployments).
Follow-up: How do you deprecate and eventually remove an old API version without breaking clients?Add a Deprecation response header to all v1 responses (Deprecation: true, Sunset: 2026-06-01) so clients can detect it programmatically. Log all v1 requests to identify which clients are still using the old version. Send direct communication to those clients with a migration timeline. After the sunset date, return 410 Gone for v1 endpoints instead of silently dropping them. The key is giving clients months of warning and making the migration path clear with a documented changelog of breaking changes between versions.
Strong Answer:
  • This is an authorization guard that needs access to both the authenticated user (from the JWT) and the resource being requested (from the route parameter). The tricky part is that the guard runs before the controller handler, so you need to decide whether to query the database in the guard.
  • The implementation: the guard injects the UsersService and reads the :id parameter from the request. It then compares request.user.id (set by the JWT guard) with the requested id. If they match, the user owns the resource.
  • For simple ownership checks (user ID matches route param), the guard can be generic: read a metadata key that specifies which route param contains the owner ID, then compare it to the authenticated user ID. Use @SetMetadata('ownerParam', 'id') and read it with Reflector.
  • For complex ownership checks (e.g., “can this user edit this comment on this post?”), the guard needs to query the database. Inject the repository and call comment = await commentRepo.findOne(commentId), then check comment.authorId === user.id. This adds a database query to every request, so consider caching.
  • The important design decision: should the guard throw 403 Forbidden or 404 Not Found? If a user requests a resource they do not own, returning 403 reveals that the resource exists (information leakage). Returning 404 is more secure — the user does not even know the resource exists. For public APIs, I prefer 404. For internal admin tools, 403 is more helpful for debugging.
Follow-up: How do you handle the case where an admin should be able to access any resource, bypassing the ownership check?Add a role check at the top of the guard: if (user.roles.includes('admin')) return true. Or use NestJS’s Reflector to compose multiple decorators: @Roles('admin') on the method allows admins through the RolesGuard, and @OwnerOnly('id') handles ownership for non-admin users. The execution order matters — put RolesGuard before OwnershipGuard in the @UseGuards() array so admins short-circuit before the ownership database query runs.