What it is: three hooks around a route handler

NestJS gives you several places to run code around a controller method without putting that code in the method itself. Middleware, guards and interceptors are the three most often confused.

  • Middleware is a function or class that runs before routing to a handler. It gets the raw request and response objects and a next() callback, like Express or Fastify middleware.
  • A guard is a class implementing CanActivate. It answers one question: should this request reach the handler? It is the standard place for authorization.
  • An interceptor is a class implementing NestInterceptor. It wraps the handler call. It can run logic before and after the handler, transform the result or the thrown error, or skip the handler entirely.

Pipes and exception filters also take part in the same request pipeline. They come up throughout this article because a concern that seems to belong to one of the three hooks sometimes belongs to them instead.

Who should use this guide

This guide is for engineers who build or maintain NestJS services and need to decide where cross-cutting logic belongs. Examples include authentication, authorization, logging, metrics, caching, validation and response shaping.

  • Feature developers adding role checks, per-route throttles or DTO validation to endpoints.
  • Platform or framework owners setting up global behavior that every route must share, such as correlation IDs, error formats and metrics.
  • Reviewers and maintainers debugging why a hook did not run, ran in an unexpected order, or could not see the data it needed.

If your logic only matters to one or two handlers and depends on data the handler loads, you may not need a hook at all. The section on alternatives covers that case.

How it works: one pipeline, fixed positions

Every incoming request goes through the same ordered pipeline:

  1. Middleware
  2. Guards
  3. Interceptors (before the handler)
  4. Pipes
  5. The handler
  6. Interceptors (after the handler)
  7. Exception filters, for anything that was thrown

Each hook type sits at a fixed position, so each one sees different context:

  • Middleware sees only the raw request, because no handler has been selected yet.
  • Guards and interceptors receive an ExecutionContext that identifies the target controller class and method. This lets them read decorator metadata.
  • Interceptors are the only hook that also sees the handler's return value, delivered as an RxJS Observable.

To choose a hook, work out what your logic needs to see and when it must run. The sections below cover the pipeline order, what each hook can access, how to bind them, and the common pitfalls.

The NestJS request lifecycle and execution order

A NestJS request passes through a fixed sequence of hooks. Each one runs at a specific point and can see a different slice of context.

  • Middleware runs first: globally bound middleware, then module-bound middleware. It works like Express-style middleware, with the raw request, the response, and next(). Routing to a controller method hasn't happened yet, so middleware cannot tell which handler will run.
  • Guards run next, in the order global, controller, then route. A guard receives an ExecutionContext, which identifies the target class and handler. That lets it read route metadata, such as required roles, and decide whether the request proceeds.
  • Interceptors run their "before" logic in the order global, controller, then route. They wrap the handler's execution as an RxJS stream.
  • Pipes run in the order global, controller, route, then parameter. They transform or validate the individual arguments the handler will receive.
  • The handler executes. Any services it calls run as part of it.
  • Interceptors run their "after" logic in reverse order: route, controller, then global. At this point they can see, and reshape, the handler's result.
  • Exception filters catch anything thrown along the way. The most specific filter is tried first: route, then controller, then global. A filter can turn the exception into a response.
  • The response is sent.

The hooks are separate tools because they sit at different positions, not because they do different kinds of work. Each position comes with its own context. So for any piece of logic, ask two things. First, what does it need to know: the raw request, the matched route and its metadata, or the handler's result? Second, at what point in the pipeline must it run, or stop the request? The answers usually point to exactly one hook.

 Request
    v
+----------------------------------------------+  raw req/res + next()
| Middleware: global > module-bound            |  no handler matched yet
+----------------------------------------------+
    v
+----------------------------------------------+  ExecutionContext + route
| Guards: global > ctrl > route                |  metadata; can deny request
+----------------------------------------------+
    v
+----------------------------------------------+  ExecutionContext; wraps
| Interceptors (before): global > ctrl > route |  handler as an RxJS stream
+----------------------------------------------+
    v
+----------------------------------------------+  per-argument transform
| Pipes: global > ctrl > route > param         |  and validation
+----------------------------------------------+
    v
+----------------------------------------------+
| Handler (+ services it calls)                |
+----------------------------------------------+
    v
+----------------------------------------------+  sees the handler result
| Interceptors (after): route > ctrl > global  |  and can reshape it
+----------------------------------------------+
    v
 Response <--- Exception filters: route > ctrl > global
               (catch anything thrown at any stage above)

The NestJS request lifecycle: each hook runs at a fixed position, in the binding order shown, and sees a different slice of context, from the raw request in middleware to the handler's result in the interceptors' after phase.

How each hook works: context access and capabilities

The answers usually depend on what each hook can see when it runs.

Middleware receives the platform's raw req, res and next(), the same signature as Express or Fastify middleware. It runs before Nest builds an ExecutionContext, so it cannot see which handler will run or read decorator metadata. That is why it suits route-agnostic work. A request-ID middleware reads an incoming x-request-id header or generates one, attaches it to req and the response, then calls next().

Guards implement CanActivate and receive an ExecutionContext, which exposes the target class and handler. A roles guard calls Reflector to read the roles set by a @Roles() decorator on the handler, then compares them with request.user. It returns a boolean, a Promise or an Observable. A falsy result stops the request with a ForbiddenException by default.

Interceptors wrap CallHandler.handle(), which returns an RxJS Observable of the handler's result. Code before handle() runs on the way in. Operators such as map, tap, catchError and timeout run on the way out. A timing interceptor records Date.now() before the call and logs the elapsed time in tap. If an interceptor never calls handle(), the handler does not run, which is how response caching returns stored values.

Pipes validate or transform one argument at a time. For example, @Param('id', ParseIntPipe) turns "42" into 42 or rejects the request with a 400. ValidationPipe checks DTOs the same way.

Exception filters catch errors thrown by guards, interceptors, pipes and the handler, and turn them into responses.

Hook Receives Sees route metadata Can short-circuit Sees handler result
Middleware req, res, next No Yes No
Guard ExecutionContext Yes Yes No
Interceptor ExecutionContext, CallHandler Yes Yes Yes
Pipe Argument value, metadata Per argument Yes, by throwing No
Filter Exception, ArgumentsHost Limited Not applicable No
import { CallHandler, CanActivate, Controller, ExecutionContext, Get, Injectable, NestInterceptor, NestMiddleware, Param, ParseIntPipe, SetMetadata, UseGuards, UseInterceptors } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { Observable, tap } from 'rxjs';

// Middleware: raw req/res/next only, no ExecutionContext. Register it in a module's configure().
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const id = (req.headers['x-request-id'] as string) ?? randomUUID();
    (req as any).requestId = id; // available to later hooks
    res.setHeader('x-request-id', id);
    next();
  }
}

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

// Guard: ExecutionContext + Reflector let it read @Roles() on the target handler/class.
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  canActivate(ctx: ExecutionContext): boolean {
    const roles = this.reflector.getAllAndOverride<string[]>('roles', [ctx.getHandler(), ctx.getClass()]);
    if (!roles) return true; // no @Roles(): route is open
    const user = ctx.switchToHttp().getRequest().user;
    return roles.some((r) => user?.roles?.includes(r)); // false -> 403 ForbiddenException
  }
}

// Interceptor: code before handle() runs on the way in; tap() runs after the handler emits.
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = Date.now();
    return next.handle().pipe(tap(() => console.log(`${ctx.getHandler().name} took ${Date.now() - start}ms`)));
  }
}

@Controller('orders')
@UseGuards(RolesGuard)
@UseInterceptors(TimingInterceptor)
export class OrdersController {
  @Get(':id')
  @Roles('admin')
  findOne(@Param('id', ParseIntPipe) id: number) { // "42" -> 42, "abc" -> 400 Bad Request
    return { id };
  }
}

A request-ID middleware, a Reflector-based roles guard, a tap-based timing interceptor and a ParseIntPipe parameter, showing what each hook can access in a NestJS app on the Express platform.

When to use which hook: a decision table for common concerns

Concern Hook Why this hook
Correlation IDs Middleware Runs first, so the ID exists for every later log line, including guard rejections.
Authentication Middleware or guard Middleware can attach the user to the request early. A guard can read handler metadata to skip public routes.
Role-based authorization Guard Needs ExecutionContext to read route metadata and must reject before the handler runs.
Rate limiting Guard (or middleware for coarse IP limits) A guard can apply per-route limits from metadata. Middleware is enough when the limit ignores the route.
Input validation Pipe Operates on each argument value after guards, and throwing stops the handler.
Response envelope shaping Interceptor Only interceptors can transform the handler's return value before serialization.
Logging and latency metrics Interceptor (plus middleware for totals) An interceptor times the handler with route context. Guard rejections never reach it, so use middleware to measure whole requests.
Caching Interceptor Can return a cached value without calling next.handle(), which skips the handler entirely.
Error normalization Exception filter Catches errors from every later stage and maps them to one response shape.

Ownership affects which rows a team uses. Platform teams usually own the globally bound rows: correlation IDs, authentication, envelope shaping, metrics and error normalization. These need to behave the same on every route. Feature teams mostly work with guards and pipes driven by decorators, such as role requirements, per-route throttles and DTO validation. That logic changes with each endpoint.

Splitting a concern across hooks is often the correct design. Authentication can establish identity in middleware or a global guard, and a route-level guard can then decide whether that identity is allowed. Keeping the two separate means feature teams can tighten authorization without touching shared identity code.

Implementing and binding hooks: scope, DI and stacking order

Middleware is registered in one of two places. app.use() in the bootstrap file attaches it to every request, but only as a plain function or a pre-built instance, so it cannot pull dependencies from the Nest container. A module that implements NestModule gets a configure(consumer) method instead. There, consumer.apply(...) takes class middleware that Nest instantiates with full injection. You can narrow it with .exclude(...) and .forRoutes(...), which accept path strings, controller classes or route objects that specify a method.

The other hooks follow the same split. app.useGlobalGuards(), useGlobalInterceptors(), useGlobalPipes() and useGlobalFilters() run outside any module, so you construct the instances yourself and resolve their dependencies by hand. Providing the class under APP_GUARD, APP_INTERCEPTOR, APP_PIPE or APP_FILTER in a module's providers array hands construction to Nest. The guard can then inject a config service, a repository or Reflector. The token applies the hook globally no matter which module declares it. Put it in the module that owns its dependencies so readers can find it.

How bound hooks stack

Hooks run from global to controller to method. Guards listed in a single @UseGuards(A, B) execute left to right, one after another, and the first one that returns false or throws stops the chain. Interceptors wrap the handler in the same outer-to-inner order. After the handler returns, the response stream passes back through them in reverse: method interceptors first, then controller, then global. Code placed after next.handle() therefore sees the result before broader interceptors transform it.

import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ApiKeyGuard } from './api-key.guard';
import { TimingInterceptor } from './timing.interceptor';
import { RequestIdMiddleware } from './request-id.middleware';
import { OrdersController } from './orders.controller';

@Module({
  imports: [ConfigModule],
  controllers: [OrdersController],
  providers: [
    // Nest constructs the guard, so it can inject ConfigService or Reflector.
    // The token makes it global, even though it is declared in this module.
    { provide: APP_GUARD, useClass: ApiKeyGuard },
    // Global interceptor: runs outermost before the handler, last on the way out.
    { provide: APP_INTERCEPTOR, useClass: TimingInterceptor },
  ],
})
export class OrdersModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      // Class middleware is instantiated by Nest with full DI.
      .apply(RequestIdMiddleware)
      // Skip the health check route entirely.
      .exclude({ path: 'orders/health', method: RequestMethod.GET })
      // Apply to every route handled by this controller.
      .forRoutes(OrdersController);
  }
}

A module that registers a global guard and interceptor through the APP_GUARD and APP_INTERCEPTOR tokens, so Nest can inject their dependencies, and binds class middleware to one controller with MiddlewareConsumer, using exclude and forRoutes.

When to avoid each hook: pitfalls and ordering surprises

Middleware for authorization

Middleware runs before Nest has resolved the route handler, so it has no ExecutionContext and cannot read handler metadata such as role decorators. Authorization logic there must re-match paths and methods itself, and that copy drifts as routes change. Use middleware to attach identity, and use a guard to decide access.

Interceptors everywhere

Every interceptor wraps the handler in another observable. Stack enough of them and the actual response shape depends on mapping logic spread across several files. Tests also have to reconstruct the whole chain to see what clients receive. After-logic has two blind spots:

  • @Res() without passthrough. Nest no longer manages the response, so mapping the returned value has no effect on what the client gets.
  • Streaming. The observable can complete when the handler returns rather than when the last byte is sent, which makes durations look shorter than they were.

Counting denied requests

Guards run before interceptors. A request rejected by a guard never reaches a timing or logging interceptor, so those metrics count only allowed traffic. To see rejections, record them in middleware or in an exception filter.

Errors from middleware

Exceptions thrown in middleware never reach controller-scoped or method-scoped filters. Third-party middleware may also send errors in its own format. Throw HttpException subclasses from guards and pipes so every error response passes through the same filters.

Global ValidationPipe

With whitelist and transform enabled, the pipe instantiates and validates a DTO on every request, which adds work to every route. Conversion can also surprise you. With implicit conversion enabled, the query string "false" becomes boolean true. Declare explicit transforms for booleans and enums.

import { CanActivate, ExecutionContext, ForbiddenException, Injectable, NestMiddleware, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request, Response, NextFunction } from 'express';

declare function decodeUser(header?: string): { id: string; roles: string[] } | undefined; // app-specific
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

@Injectable()
export class IdentityMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // Attach identity only. No path or method matching, no access decisions.
    (req as any).user = decodeUser(req.headers.authorization);
    const start = Date.now();
    // 'finish' fires for every response, including 403s from guards that interceptors never see.
    res.on('finish', () => {
      console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
    });
    next();
  }
}

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    // Handler metadata is available here because the route is already resolved.
    const required = this.reflector.getAllAndOverride<string[]>('roles', [ctx.getHandler(), ctx.getClass()]);
    if (!required || required.length === 0) return true;
    const user = ctx.switchToHttp().getRequest().user;
    if (!required.some((role) => user?.roles?.includes(role))) {
      // HttpException subclass, so the error goes through the same exception filters as other errors.
      throw new ForbiddenException('Insufficient role');
    }
    return true;
  }
}

Middleware attaches identity and logs every response, including guard rejections, while a guard reads role metadata from the resolved handler and throws a ForbiddenException so error responses stay consistent.

Non-HTTP transports and alternatives to framework hooks

Middleware is the only one of these hooks tied to HTTP. It runs inside the underlying HTTP adapter, so microservice message handlers and WebSocket gateways never invoke it. Guards, interceptors, pipes and exception filters run on all three transports because they receive an ExecutionContext rather than a raw request. Call getType() to find out which transport is active, then use switchToHttp(), switchToRpc() or switchToWs() to reach the payload, client or metadata. This determines where shared logic belongs. Suppose a tenant check is written as middleware and later needs to cover a message queue consumer. You will end up rewriting it as a guard. Writing it as a guard from the start avoids that.

In a hybrid application, check whether your globally registered enhancers also apply to the connected microservices. By default they do not. They are only inherited when you opt in at connection time.

Some concerns do not need a framework hook:

  • Injected services called from the handler. Use these when the logic depends on the handler's own data, such as checking ownership after loading a record or writing an audit entry that includes the computed result. A visible call in the method body is easier to follow than behavior attached through decorator metadata. It also needs no ExecutionContext mocking in unit tests.
  • Custom parameter decorators. Use these to extract request data, such as the current user or a correlation ID, without giving the handler the full request object.
  • Infrastructure in front of the application. A reverse proxy or gateway layer can handle TLS termination, coarse rate limiting, body size limits and IP filtering. These are policies that should reject traffic before it reaches Node.

Use an explicit service call when only one or two handlers need the logic, or when the logic needs the handler's own data.

Impact on development cost and engineering time

Choosing the right hook pays off mainly by reducing how much code repeats. A guard that enforces role checks, a global pipe that validates DTOs, and an interceptor that wraps responses in a standard envelope each replace logic that would otherwise be copied into every handler. Each piece of policy then lives in one class that you can unit test with a mocked ExecutionContext or CallHandler, without starting the HTTP stack.

Illustrative estimate (assumed numbers, not a benchmark). Assume 80 handlers, one policy change, and 2 minutes to update and review each call site. Per-handler cost = H × t = 80 × 2 = 160 minutes. Centralized cost = one hook change plus its tests, assumed at 30 minutes. Put your own values in for H and t. The gap widens as the number of handlers grows.

Choosing the wrong hook costs time in less visible ways:

  • Ordering surprises. For example, an interceptor that expects a validated body, even though pipes run after interceptors' pre-handler logic.
  • Hidden logic. Response mapping buried in an interceptor that no one thinks to inspect.
  • Duplicated route matching. Middleware forRoutes() patterns that drift out of sync with controller paths.

Registration style also affects how easy a hook is to find. APP_GUARD and APP_INTERCEPTOR providers get full DI, but they can be declared in any module, so a global hook may sit far from where readers look for it. useGlobal* calls in main.ts are easy to see in one place, but they are created outside the module system, so they cannot inject module providers.

Keep a short document that maps each concern to its hook. This matters most for split concerns, such as authentication handled in middleware and authorization handled in a guard. Keep hooks thin: have them extract what they need from the context and delegate the decision to an injectable service that the rest of the codebase can reuse and test directly.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs interceptor vs middleware vs guard, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.