Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
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)
- Business logic (delegate to services)
- Database access (delegate to repositories/services)
- Complex data transformation (delegate to services)
- Authentication logic (use guards)
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
@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
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:Query Parameters
Extract data from the query string:Request Body
Extract data from the request body:Headers
Extract data from request headers:Request Object
Access the full request object when needed:Response Object
Control the response directly:@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: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.- Incoming Request - HTTP request arrives at the server
- Middleware - Global and route-specific middleware runs (think Express middleware: logging, CORS, body parsing)
- Guards - Authentication and authorization checks (“Should this request proceed at all?”)
- Interceptors (Before) - Pre-processing (logging, request timing, cache lookup)
- Pipes - Validation and transformation of input data (DTOs validated here)
- Controller - Route handler method executes (thin — just delegates to service)
- Service - Business logic executes (the real work happens here)
- Interceptors (After) - Post-processing (response wrapping, cache storage)
- Exception Filters - Handle any exceptions thrown during the pipeline (format error responses)
- Response - HTTP response sent to client
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.| Aspect | Middleware | Guards | Interceptors | Pipes |
|---|---|---|---|---|
| Primary Job | Raw request/response processing | Access control (yes/no) | Wrap handler execution (before + after) | Validate/transform individual values |
| Has Access To | req, res, next | ExecutionContext (handler metadata, class) | ExecutionContext + handler’s Observable return | The specific value being transformed |
| Can Read Route Metadata? | No | Yes (via Reflector) | Yes (via Reflector) | No |
| Can Modify Response? | Yes (via res) | No (only allow/deny) | Yes (via RxJS map operator) | No (only transforms input) |
| Can Short-Circuit? | Yes (skip next()) | Yes (return false) | Yes (return cached Observable) | Yes (throw validation error) |
| Execution Order | 1st | 2nd | 3rd (before) + 7th (after) | 4th |
| Scope Options | Global, route-specific | Global, controller, method | Global, controller, method | Global, controller, method, param |
| DI Support | Class-based only | Yes | Yes | Yes |
| Can Be Async? | Yes | Yes | Yes (RxJS) | Yes |
| Express/Fastify Aware? | Yes (platform-specific) | No (uses ExecutionContext) | No (uses ExecutionContext) | No (value-level) |
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 inmain.ts:
Custom Validation
Create custom validators: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)
- 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 usingconfigure method:
Route-Specific Middleware
Excluding Routes
Multiple Middleware
Global Middleware
Apply middleware globally inmain.ts:
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 pipelinefalse- Request is denied (NestJS automatically throws ForbiddenException)
Basic Guard
Using Guards
Controller-level:Execution Context
TheExecutionContext provides access to:
- Request object
- Response object
- Next function
- Handler (controller method)
- Class (controller class)
Role-Based Guard
Async Guards
Guards can be async: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.)
- 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: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: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.
class-validator does not validate nested objects by default. You must add @ValidateNested() and @Type(() => NestedDto) from class-transformer to trigger recursive validation.
@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.
@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
- Keep controllers thin
- Use DTOs for validation
- Organize routes by feature
- Use appropriate HTTP methods
- Handle errors gracefully
- Document your API
Interview Deep-Dive
Compare middleware, guards, interceptors, and pipes in NestJS. When would you use each, and what happens if you put logic in the wrong layer?
Compare middleware, guards, interceptors, and pipes in NestJS. When would you use each, and what happens if you put logic in the wrong layer?
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 viaReflector. Use guards for authentication (is the JWT valid?) and authorization (does this user have theadminrole?). 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.
ParseIntPipeconverts a string route param to a number.ValidationPipevalidates a DTO againstclass-validatordecorators. 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.
Your API returns inconsistent error responses -- some routes return different error shapes. How do you fix this across the entire application?
Your API returns inconsistent error responses -- some routes return different error shapes. How do you fix this across the entire application?
Strong Answer:
- This is a global exception filter problem. NestJS has a built-in exception filter that formats
HttpExceptionsubclasses 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
AllExceptionsFilterthat catches every exception (use@Catch()with no arguments) and formats it into a consistent shape. Register it inmain.tswithapp.useGlobalFilters(new AllExceptionsFilter()). - The filter should handle three cases: (1)
HttpExceptionsubclasses — 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.tsusingapp.useGlobalFilters(), it cannot inject dependencies (no DI). If your filter needs to inject a logger service, register it as a provider inAppModuleusingAPP_FILTER:{ provide: APP_FILTER, useClass: AllExceptionsFilter }. This gives you DI support. - I also add a
correlationIdto every error response so the frontend can include it in bug reports, and the backend team can search logs by that ID.
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.How would you implement API versioning in NestJS? What are the trade-offs between different versioning strategies?
How would you implement API versioning in NestJS? What are the trade-offs between different versioning strategies?
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 inmain.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).
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.You need to build a guard that allows access only if the user owns the requested resource. How do you implement this ownership check?
You need to build a guard that allows access only if the user owns the requested resource. How do you implement this ownership check?
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
UsersServiceand reads the:idparameter from the request. It then comparesrequest.user.id(set by the JWT guard) with the requestedid. 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 withReflector. - 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 checkcomment.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.
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.