DEV Community

Mubashir Taj
Mubashir Taj

Posted on

NestJS Request Lifecycle: Guards, Interceptors, and Pipes in Order

NestJS is often described as an opinionated framework, but its real value is that the opinion is explicit. A request does not wander through a pile of middleware and hope for the best. It passes through a defined sequence of stages, each with a clear job, and each stage can stop the request, transform it, or pass it onward. Developers who learn that sequence stop guessing about where a piece of logic belongs. They also stop writing the same check in three different places, because the lifecycle tells them exactly one place where it fits.

The full order, for a request that reaches a controller, is as follows. First, middleware runs. Second, guards run. Third, interceptors run before the handler. Fourth, pipes run on the handler arguments. Fifth, the handler itself runs. Sixth, interceptors run after the handler, wrapping the result. Seventh, exception filters run if anything threw. That sentence is worth memorizing, because almost every NestJS debugging session comes down to asking which stage you are in and what that stage is allowed to do.

Middleware is the outermost layer, and it is the closest thing NestJS has to plain Express middleware. It runs before routing is fully resolved in some cases, and it is best suited to concerns that apply broadly and do not need NestJS features such as dependency injection into the request context, or at least do not need them in a sophisticated way. Logging, request identifiers, CORS handling, and raw body parsing are natural middleware jobs. Middleware can also short circuit a request by sending a response directly, but it cannot easily use guards, and it does not know which controller method will handle the request. That limitation is the main reason to move logic inward when it needs routing context.

Guards come next, and they answer a single question: is this request allowed to proceed? A guard returns true, false, or a promise of either, and it can also throw an exception. Authentication and authorization are the canonical guard jobs. A guard that checks a JWT, loads the user, and attaches that user to the request object is doing exactly what guards are for. So is a role guard that checks whether the authenticated user has permission for the route. The important property of guards is that they run before interceptors and before pipes. That means an unauthenticated request never reaches your validation logic, never triggers an interceptor that might log sensitive data, and never wastes work parsing a body that will be rejected anyway. Putting authorization in a guard is therefore not just a style choice. It is a security ordering choice.

A common mistake is to put authorization logic inside the handler itself, after the service has already loaded data. That pattern leaks in two ways. It wastes a database query on a request that should have been rejected, and it invites inconsistency, because every handler must remember to perform the check. A guard centralizes the check and makes it visible at the route definition, where a reviewer can see it without reading the handler body. Consider the following guard, which is intentionally small. It does one job, and it does it before anything else in the handler chain can run.

import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    if (!user) {
      throw new UnauthorizedException('Authentication required');
    }
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Interceptors are the most misunderstood stage, because they wrap the handler on both sides. An interceptor that runs before the handler can log the start of a request, measure timing, or modify the incoming context. The same interceptor, after the handler returns, receives the result as an observable stream and can transform it, cache it, or measure the total duration. That two sided nature makes interceptors the right home for cross cutting behavior that needs to see both the request and the response: response shaping, serialization of outgoing data, timeout handling, and performance logging. Interceptors are not the place for authorization, because they run after guards, and they are not the place for input validation, because they run before pipes but operate at a coarser level than individual arguments.

The distinction between interceptors and middleware deserves attention, because they look similar from a distance. Middleware is framework agnostic and request focused. Interceptors are NestJS native, they have access to the execution context, they can use dependency injection fully, and they can transform the response stream with RxJS operators. If your logic needs to know which handler is running, or needs to transform the handler's return value, it belongs in an interceptor. If it needs to run for every request including requests that never reach a controller, middleware is the better fit. Choosing between them by asking what context the logic needs will get the answer right most of the time.

Pipes run after interceptors and before the handler, and they operate on individual arguments. A pipe receives a value, validates it or transforms it, and returns the result. If validation fails, the pipe throws, and the handler never runs. This is where NestJS validation belongs. The built in validation pipe, combined with class validator decorators on a data transfer object, turns a messy body parsing problem into a declarative one. You describe the shape you expect, and NestJS rejects anything else with a structured error before your business logic sees it. Pipes are also used for transformation, such as parsing a route parameter from a string into an integer, or parsing a query string into a typed object. The key property is granularity: pipes see one argument at a time, which makes them precise but unsuitable for decisions that depend on the whole request.

Here is a typical data transfer object and controller usage. Notice how little code the controller itself contains. The pipe has already guaranteed that the input matches the declared shape, so the handler can focus on behavior rather than defensive checks.

import { IsString, IsInt, Min, MaxLength } from 'class-validator';

export class CreatePostDto {
  @IsString()
  @MaxLength(200)
  title: string;

  @IsString()
  body: string;

  @IsInt()
  @Min(1)
  authorId: number;
}
Enter fullscreen mode Exit fullscreen mode
import { Body, Controller, Post, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';

@Controller('posts')
export class PostsController {
  @Post()
  @UseGuards(JwtAuthGuard)
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
  create(@Body() dto: CreatePostDto) {
    // At this point dto is validated and stripped of unknown fields.
    return { title: dto.title };
  }
}
Enter fullscreen mode Exit fullscreen mode

The handler itself is the center of the lifecycle, and by the time it runs, the framework has already done a great deal of work. Authentication has passed, authorization has passed, the input has been validated and transformed, and any before logic in interceptors has run. The handler should therefore be thin. It calls a service, returns a result, and lets the framework handle the rest. Handlers that revalidate input, recheck permissions, or manually serialize output are usually compensating for a stage that was skipped earlier. Moving that logic back to its proper stage simplifies the handler and makes the system easier to test, because each stage can be tested in isolation.

After the handler returns, interceptors run again in reverse order, transforming the result. Then the response is sent. If anything in the chain threw, exception filters run. Exception filters are the last stage, and they convert thrown errors into HTTP responses. NestJS ships with a default filter that handles HTTP exceptions sensibly, but custom filters are valuable for two reasons. First, they let you normalize error shapes across the whole application, so clients receive a consistent structure regardless of which layer failed. Second, they let you map domain errors, such as a record not found in the service layer, into the correct HTTP status without leaking internal details. A filter should log the error with enough context to debug it, then return a safe response to the client. It should never return stack traces or database error messages to callers.

Execution order also applies to scope. Guards, interceptors, pipes, and filters can be bound globally, at the controller level, or at the individual route level. More specific bindings run after more general ones in some stages and before them in others, and the documentation's ordering rules repay careful reading. The practical rule is to bind broadly only when the behavior truly applies everywhere. A global validation pipe is usually correct. A global guard that requires authentication is correct only if every route, including health checks and public endpoints, should require it. Otherwise, prefer controller or route level bindings, and exempt public routes explicitly rather than weakening a global guard with special cases.

Top comments (0)