Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NestJS Middleware, Guards, Interceptors & Pipes

For .NET engineers who know: ASP.NET Core middleware, action filters (IActionFilter, IAsyncActionFilter), authorization filters ([Authorize]), result filters, exception middleware, and model binding with Data Annotations You’ll learn: How NestJS’s request pipeline components map to ASP.NET Core’s filter and middleware system — the names changed, the concepts did not Time: 20-30 min read

The .NET Way (What You Already Know)

ASP.NET Core’s request pipeline is a layered architecture. Middleware runs first in the order you register it. Then, for MVC requests, the filter pipeline takes over: authorization filters, resource filters, model binding, action filters (before and after), result filters, and exception filters.

// Program.cs — Middleware pipeline (runs for every request)
app.UseExceptionHandler("/error");  // Outermost — catches unhandled exceptions
app.UseHttpsRedirection();
app.UseAuthentication();            // Sets HttpContext.User
app.UseAuthorization();             // Checks [Authorize] attributes
app.UseRateLimiter();
app.MapControllers();               // Routes to MVC pipeline
// Global action filter — registered in AddControllers()
builder.Services.AddControllers(options =>
{
    options.Filters.Add<LoggingFilter>();      // Runs around every action
    options.Filters.Add<ValidationFilter>();   // Validates model state
});

// [Authorize] — Authorization filter (runs before action filters)
[Authorize(Roles = "Admin")]
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto) { /* ... */ }

The execution order in ASP.NET Core MVC:

graph TD
    A1["Request"]
    A2["Middleware pipeline (UseX)"]
    A3["Authorization filters ([Authorize])"]
    A4["Resource filters"]
    A5["Model binding ([FromBody], [FromQuery])"]
    A6["Action filters — OnActionExecuting"]
    A7["Your controller action"]
    A8["Action filters — OnActionExecuted"]
    A9["Result filters — OnResultExecuting"]
    A10["Response written"]
    A11["Result filters — OnResultExecuted"]
    A12["Exception filters (catch anything above)"]

    A1 --> A2 --> A3 --> A4 --> A5 --> A6 --> A7 --> A8 --> A9 --> A10 --> A11
    A2 --> A12

NestJS has the same structure. Every concept has an equivalent; it’s the naming that changed.

The NestJS Way

The Complete Pipeline

graph TD
    N1["Request"]
    N2["Middleware (global and module-level)"]
    N3["Guards (global → controller → handler)"]
    N4["Interceptors — before (global → controller → handler)"]
    N5["Pipes (global → controller → handler)"]
    N6["Controller handler method"]
    N7["Interceptors — after (handler → controller → global, reverse order)"]
    N8["Exception Filters (caught at any point above)"]
    N9["Response"]

    N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7 --> N9
    N2 --> N8
    N8 --> N9

Side-by-side with ASP.NET Core:

ASP.NET CoreNestJS EquivalentRuns At
app.Use*() middlewareMiddleware (implements NestMiddleware)Outermost, before the rest of the pipeline
Authorization filter ([Authorize])Guard (implements CanActivate)After middleware, before interceptors
Action filter — beforeInterceptor — beforeAfter guards, before pipes
Model binding ([FromBody], [FromQuery])Pipe (implements PipeTransform)After interceptors, immediately before the handler
Data Annotations / FluentValidationPipe + class-validator or ZodSame position as model binding
Action filter — afterInterceptor — afterAfter handler, before response
Result filterInterceptor — after (response transformation)Same position
Exception middlewareException Filter (implements ExceptionFilter)Catches exceptions from anywhere in the pipeline

Middleware

NestJS middleware is functionally identical to ASP.NET Core middleware. It receives the request and response objects and a next() function.

// logging.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

// Equivalent to writing app.Use(async (context, next) => { ... }) in Program.cs
// Or implementing IMiddleware in .NET
@Injectable()
export class LoggingMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const { method, originalUrl } = req;
    const start = Date.now();

    // The equivalent of calling next.Invoke() in .NET middleware
    res.on('finish', () => {
      const ms = Date.now() - start;
      console.log(`${method} ${originalUrl} ${res.statusCode} — ${ms}ms`);
    });

    next(); // Call next middleware/handler
  }
}

Middleware is applied in the module, not globally in main.ts:

// app.module.ts
import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { LoggingMiddleware } from './logging.middleware';
import { OrdersModule } from './orders/orders.module';

@Module({
  imports: [OrdersModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggingMiddleware)
      // Apply to all routes — equivalent to app.Use(middleware) before MapControllers()
      .forRoutes('*');

    // Or be specific:
    // .forRoutes({ path: 'orders', method: RequestMethod.ALL })
    // .forRoutes(OrdersController)  // Apply only to OrdersController's routes
  }
}

For simple middleware, you can use a function instead of a class (equivalent to the inline app.Use(async (context, next) => { }) style in .NET):

// Simple function-based middleware
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
  res.header('Access-Control-Allow-Origin', '*');
  next();
}

// Apply it the same way
consumer.apply(corsMiddleware).forRoutes('*');

Guards — The [Authorize] Equivalent

A Guard answers one question: should this request be allowed to proceed? It returns true to allow or false to block. This is exactly what ASP.NET’s authorization filter does — check the principal, return 403 if unauthorized.

// auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';

// Equivalent to implementing IAuthorizationFilter in ASP.NET Core
// or using [Authorize] with a custom AuthorizationRequirement
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractToken(request);

    if (!token) {
      // Equivalent to context.Result = new UnauthorizedResult() in ASP.NET
      throw new UnauthorizedException('No token provided');
    }

    // Validate the token (in a real app: verify JWT signature, check expiry, etc.)
    // See Article 4.3 for the Clerk integration
    const payload = this.validateToken(token);
    if (!payload) {
      throw new UnauthorizedException('Invalid token');
    }

    // Attach user to request (equivalent to HttpContext.User in ASP.NET)
    request['user'] = payload;
    return true;
  }

  private extractToken(request: Request): string | null {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : null;
  }

  private validateToken(token: string): Record<string, unknown> | null {
    // Real implementation in Article 4.3
    return token === 'valid-token' ? { userId: 1, roles: ['admin'] } : null;
  }
}

Apply guards at three levels, mirroring ASP.NET’s global/controller/action filter scopes:

// Global — equivalent to AddControllers(options => options.Filters.Add<AuthFilter>())
// In main.ts:
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard());

// Controller level — equivalent to [Authorize] on the controller class
@Controller('orders')
@UseGuards(AuthGuard)   // All methods in this controller require auth
export class OrdersController { /* ... */ }

// Method level — equivalent to [Authorize] on a specific action
@Get('admin-only')
@UseGuards(AuthGuard, RolesGuard)  // Multiple guards — all must pass
adminOnly() { /* ... */ }

// Allow anonymous on a specific method when the controller is globally guarded
// Equivalent to [AllowAnonymous] in ASP.NET
@Get('public')
@Public()  // Custom decorator that sets metadata (shown below)
publicEndpoint() { /* ... */ }

The @Public() decorator pattern is the NestJS equivalent of [AllowAnonymous]:

// public.decorator.ts — custom metadata decorator
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// Update AuthGuard to check for this metadata:
import { Reflector } from '@nestjs/core';

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

  canActivate(context: ExecutionContext): boolean {
    // Check if the route is marked @Public() — if so, skip auth
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),   // Check the method first
      context.getClass(),     // Then check the controller class
    ]);
    if (isPublic) return true;

    // ... rest of auth logic
    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractToken(request);
    if (!token) throw new UnauthorizedException();
    // ...
    return true;
  }
}

Role-based authorization — the [Authorize(Roles = "Admin")] equivalent:

// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);

    // No @Roles() decorator — allow through
    if (!requiredRoles || requiredRoles.length === 0) return true;

    const request = context.switchToHttp().getRequest<Request>();
    const user = request['user']; // Set by AuthGuard earlier in the pipeline

    if (!user) throw new UnauthorizedException();

    const hasRole = requiredRoles.some((role) =>
      (user.roles as string[]).includes(role),
    );
    if (!hasRole) throw new ForbiddenException('Insufficient permissions');

    return true;
  }
}

// Usage — equivalent to [Authorize(Roles = "Admin")]
@Get('reports')
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin')
getAdminReports() { /* ... */ }

Interceptors — Action Filters

Interceptors wrap the request handler — they can execute code before the handler, observe the result, transform it, or intercept errors. This maps directly to IAsyncActionFilter’s OnActionExecutionAsync:

// C# — async action filter
public class LoggingFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var sw = Stopwatch.StartNew();
        var result = await next(); // Calls the action
        sw.Stop();
        _logger.LogInformation("Completed in {Ms}ms", sw.ElapsedMilliseconds);
    }
}
// TypeScript — equivalent NestJS interceptor
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const { method, url } = context.switchToHttp().getRequest();
    const start = Date.now();

    return next.handle().pipe(
      // tap() runs AFTER the handler completes (like OnActionExecuted)
      tap(() => {
        const ms = Date.now() - start;
        console.log(`${method} ${url} — ${ms}ms`);
      }),
    );
  }
}

The Observable return type and RxJS pipe operators replace the async continuation pattern. next.handle() is equivalent to await next() in the C# filter — it returns an observable that emits the handler’s response. The RxJS operators let you transform, delay, or replace that response.

A response transformation interceptor — the equivalent of a result filter that wraps all responses in an envelope:

// C# — result filter wrapping responses
public class ResponseEnvelopeFilter : IResultFilter
{
    public void OnResultExecuting(ResultExecutingContext context)
    {
        if (context.Result is ObjectResult obj)
        {
            context.Result = new ObjectResult(new { data = obj.Value, success = true });
        }
    }
    public void OnResultExecuted(ResultExecutedContext context) { }
}
// TypeScript — interceptor wrapping responses in an envelope
import { map } from 'rxjs';

export interface ResponseEnvelope<T> {
  data: T;
  success: boolean;
  timestamp: string;
}

@Injectable()
export class ResponseEnvelopeInterceptor<T>
  implements NestInterceptor<T, ResponseEnvelope<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<ResponseEnvelope<T>> {
    return next.handle().pipe(
      map((data) => ({
        data,
        success: true,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

Caching interceptor — intercept before the handler to short-circuit if cached:

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(private readonly cacheService: CacheService) {}

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<unknown>> {
    const request = context.switchToHttp().getRequest<Request>();
    const key = `${request.method}:${request.url}`;

    const cached = await this.cacheService.get(key);
    if (cached) {
      // Short-circuit — return cached value without calling the handler
      // Equivalent to: context.Result = new ObjectResult(cached); return; in C#
      return of(cached);
    }

    return next.handle().pipe(
      tap(async (data) => {
        await this.cacheService.set(key, data, 60); // Cache for 60 seconds
      }),
    );
  }
}

Apply interceptors at all three levels, same as guards:

// Global — in main.ts
app.useGlobalInterceptors(new LoggingInterceptor());

// Controller level
@Controller('orders')
@UseInterceptors(LoggingInterceptor)
export class OrdersController { /* ... */ }

// Method level
@Get(':id')
@UseInterceptors(CacheInterceptor)
findOne(@Param('id', ParseIntPipe) id: number) { /* ... */ }

Pipes — Model Binding and Validation

Pipes are the model binding + validation layer. In ASP.NET Core, model binding converts the HTTP request into typed parameters and Data Annotations validate the bound model. NestJS pipes do the same thing in one step.

Built-in pipes that map to ASP.NET’s automatic type conversion:

// ParseIntPipe — equivalent to routing a parameter declared as int in C#
// Throws 400 BadRequest if the parameter is not a valid integer
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { /* ... */ }

// ParseUUIDPipe — validates UUID format
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) { /* ... */ }

// ParseBoolPipe — 'true'/'false' string to boolean
@Get()
findAll(@Query('active', ParseBoolPipe) active: boolean) { /* ... */ }

// DefaultValuePipe — like a default parameter value
@Get()
findAll(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) { /* ... */ }

For DTO validation, NestJS uses class-validator (attribute-based, like Data Annotations) or Zod. We recommend Zod for new projects because it co-locates the type definition and the validation schema, eliminating the redundancy of separate DTO classes and validation attributes.

Approach 1: class-validator (familiar to .NET engineers)

pnpm add class-validator class-transformer
// create-order.dto.ts — class-validator approach (like Data Annotations)
import { IsNumber, IsArray, IsOptional, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class OrderItemDto {
  @IsNumber()
  productId: number;

  @IsNumber()
  @Min(1)
  quantity: number;

  @IsNumber()
  @Min(0)
  unitPriceCents: number;
}

export class CreateOrderDto {
  @IsNumber()
  customerId: number;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  items: OrderItemDto[];

  @IsString()
  @IsOptional()
  notes?: string;
}
// Enable the validation pipe globally in main.ts
// Equivalent to [ApiController] which auto-validates ModelState in ASP.NET
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,       // Strip properties not in the DTO (like [Bind(Include=...)])
      forbidNonWhitelisted: true, // Return 400 if extra properties are sent
      transform: true,       // Transform plain objects to DTO instances (needed for class-validator)
    }),
  );
  await app.listen(3000);
}

Approach 2: Zod (recommended — schema and type in one place)

pnpm add zod
// orders/dto/create-order.dto.ts — Zod approach
import { z } from 'zod';

export const OrderItemSchema = z.object({
  productId: z.number().int().positive(),
  quantity: z.number().int().min(1),
  unitPriceCents: z.number().int().min(0),
});

export const CreateOrderSchema = z.object({
  customerId: z.number().int().positive(),
  items: z.array(OrderItemSchema).min(1),
  notes: z.string().optional(),
});

// The TypeScript type is inferred from the schema — no separate interface needed
// Equivalent to: your DTO class IS also your validation definition
export type CreateOrderDto = z.infer<typeof CreateOrderSchema>;
// zod-validation.pipe.ts — custom pipe that validates using a Zod schema
import {
  PipeTransform,
  Injectable,
  ArgumentMetadata,
  BadRequestException,
} from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown, _metadata: ArgumentMetadata) {
    const result = this.schema.safeParse(value);
    if (!result.success) {
      // Format Zod errors like ASP.NET's ModelState validation errors
      const errors = (result.error as ZodError).errors.map((e) => ({
        field: e.path.join('.'),
        message: e.message,
      }));
      throw new BadRequestException({ message: 'Validation failed', errors });
    }
    return result.data;
  }
}
// Use the pipe on a specific body parameter
@Post()
create(
  @Body(new ZodValidationPipe(CreateOrderSchema)) dto: CreateOrderDto,
) {
  return this.ordersService.create(dto);
}

Custom pipes for domain-specific transformations:

// trim-strings.pipe.ts — trim whitespace from all string properties
// Equivalent to a custom model binder in ASP.NET Core
@Injectable()
export class TrimStringsPipe implements PipeTransform {
  transform(value: unknown): unknown {
    if (typeof value === 'string') return value.trim();
    if (typeof value === 'object' && value !== null) {
      return Object.fromEntries(
        Object.entries(value).map(([k, v]) => [
          k,
          typeof v === 'string' ? v.trim() : v,
        ]),
      );
    }
    return value;
  }
}

Exception Filters

Exception filters catch unhandled exceptions from anywhere in the pipeline and convert them to HTTP responses. In ASP.NET Core, this is a combination of exception middleware (UseExceptionHandler) and exception filters (IExceptionFilter).

NestJS’s built-in global exception filter already handles HttpException subclasses (all the NotFoundException, BadRequestException, etc.) and converts unrecognized exceptions to 500 Internal Server Error. You typically add custom exception filters for:

  1. Transforming ORM-specific exceptions (Prisma errors, DB constraint violations) into meaningful HTTP responses
  2. Adding structured error logging before the response is sent
  3. Customizing the error response format
// http-exception.filter.ts — custom exception filter
// Equivalent to implementing IExceptionFilter in ASP.NET or UseExceptionHandler middleware
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

// @Catch(HttpException) — only catches HttpException and subclasses
// @Catch() with no argument catches ALL exceptions
@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();

    // Structure the error response (equivalent to ProblemDetails in ASP.NET)
    const errorBody = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message:
        typeof exceptionResponse === 'string'
          ? exceptionResponse
          : (exceptionResponse as Record<string, unknown>).message,
    };

    if (status >= 500) {
      this.logger.error(`${request.method} ${request.url}`, exception.stack);
    }

    response.status(status).json(errorBody);
  }
}

For Prisma-specific errors (analogous to catching DbUpdateConcurrencyException or SqlException in .NET):

// prisma-exception.filter.ts
import { Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
  catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse();

    // Prisma error codes: https://www.prisma.io/docs/reference/api-reference/error-reference
    switch (exception.code) {
      case 'P2002':
        // Unique constraint violation — equivalent to catching SqlException 2627 in .NET
        return response.status(HttpStatus.CONFLICT).json({
          statusCode: 409,
          message: 'A record with this value already exists',
          field: exception.meta?.target,
        });
      case 'P2025':
        // Record not found — equivalent to catching NotFoundException
        return response.status(HttpStatus.NOT_FOUND).json({
          statusCode: 404,
          message: 'Record not found',
        });
      default:
        return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
          statusCode: 500,
          message: 'Database error',
        });
    }
  }
}

Register exception filters globally or at any scope:

// Global — in main.ts
app.useGlobalFilters(new HttpExceptionFilter(), new PrismaExceptionFilter());

// Controller level
@Controller('orders')
@UseFilters(HttpExceptionFilter)
export class OrdersController { /* ... */ }

// Method level
@Post()
@UseFilters(PrismaExceptionFilter)
create(@Body() dto: CreateOrderDto) { /* ... */ }

Execution Order in Practice

Building on the diagram from the introduction, here is the same request traced through both frameworks side-by-side.

Request: POST /api/orders (authenticated user, valid body)

ASP.NET Core execution trace:

graph TD
    AE1["1. UseExceptionHandler\n(outermost middleware — wraps everything)"]
    AE2["2. UseAuthentication\n(sets HttpContext.User from JWT)"]
    AE3["3. UseAuthorization\n(checks [Authorize] attribute)"]
    AE4["4. LoggingFilter.OnActionExecuting"]
    AE5["5. ValidationFilter.OnActionExecuting\n(checks ModelState)"]
    AE6["6. [FromBody] model binding"]
    AE7["7. OrdersController.Create() executes"]
    AE8["8. LoggingFilter.OnActionExecuted"]
    AE9["9. ResponseEnvelopeFilter.OnResultExecuting"]
    AE10["10. Response written"]
    AE11["11. ResponseEnvelopeFilter.OnResultExecuted"]
    AE1 --> AE2 --> AE3 --> AE4 --> AE5 --> AE6 --> AE7 --> AE8 --> AE9 --> AE10 --> AE11

NestJS execution trace:

graph TD
    NE1["1. LoggingMiddleware.use()\n(middleware — outermost)"]
    NE2["2. AuthGuard.canActivate() (guards)"]
    NE3["3. RolesGuard.canActivate() (guards, if present)"]
    NE4["4. LoggingInterceptor.intercept() — before next.handle()"]
    NE5["5. ResponseEnvelopeInterceptor.intercept() — before next.handle()"]
    NE6["6. ZodValidationPipe.transform() (pipes)"]
    NE7["7. ParseIntPipe.transform() (other pipes)"]
    NE8["8. OrdersController.create() executes"]
    NE9["9. ResponseEnvelopeInterceptor.intercept() — after next.handle() (reverse order)"]
    NE10["10. LoggingInterceptor.intercept() — after next.handle() (reverse order)"]
    NE11["11. Response written"]
    NE1 --> NE2 --> NE3 --> NE4 --> NE5 --> NE6 --> NE7 --> NE8 --> NE9 --> NE10 --> NE11

Note that interceptors wrap in reverse order on the way back — the last interceptor applied is the first to process the response. This is identical to how the middleware pipeline unwinds in .NET.

Rate Limiting — Practical Middleware Example

Rate limiting in ASP.NET Core (added natively in .NET 7) is middleware. In NestJS, it’s typically a guard using a library:

pnpm add @nestjs/throttler
// app.module.ts — Configure the throttler module
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRoot([{
      name: 'default',
      ttl: 60000,   // 60 seconds (milliseconds)
      limit: 100,   // 100 requests per window
    }]),
  ],
  providers: [
    {
      // Register ThrottlerGuard globally — applies to all endpoints
      // Equivalent to: app.UseRateLimiter() + global rate limit policy
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

// Override the limit on specific endpoints
@Post('register')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Only 5 per minute
register(@Body() dto: RegisterDto) { /* ... */ }

// Skip rate limiting on specific endpoints
@Get('health')
@SkipThrottle()
healthCheck() { return { status: 'ok' }; }

Key Differences

ConceptASP.NET CoreNestJSImportant Nuance
Cross-cutting concern modelMiddleware + Filter pipelineMiddleware + Guards + Interceptors + Pipes + Exception FiltersMore granular naming in NestJS
Authorization checkAuthorization filter ([Authorize])Guard (implements CanActivate)Guards can also be used for non-auth logic
Before/after action logicAction filter (IActionFilter)Interceptor (implements NestInterceptor)Interceptors use RxJS Observable chain
Model bindingFramework-automatic, attribute-drivenPipe-driven, explicitMust add ValidationPipe; not automatic
ValidationData Annotations (auto) + FluentValidationclass-validator via ValidationPipe or Zod pipeMust opt in explicitly
Error to HTTP responsereturn NotFound() or exception filterthrow new NotFoundException() or exception filterServices throw; filters catch
Global filter registrationoptions.Filters.Add<T>() in AddControllers()app.useGlobalGuards/Interceptors/Pipes/Filters()Separate registration per pipeline component type
Decorator metadata readingReflection over attributesReflector.getAllAndOverride()Explicit metadata reading required for custom decorators
Filter/guard orderingConfiguration orderOuter-to-inner on entry, inner-to-outer on exitConsistent with middleware pipeline

The most significant difference: in ASP.NET Core, model validation happens automatically for all [ApiController] controllers. In NestJS, you must explicitly add ValidationPipe globally or per-endpoint. Forgetting this is the most common bug new NestJS engineers encounter from the .NET world — your DTOs will silently accept invalid data.

Gotchas for .NET Engineers

Gotcha 1: Validation Does Not Happen Automatically

In ASP.NET Core, [ApiController] automatically validates the model and returns 400 if validation fails. In NestJS, there is no equivalent automatic validation. If you send invalid data to an endpoint without a ValidationPipe, NestJS will happily pass the invalid data to your controller.

// WRONG — The DTO has class-validator decorators, but without ValidationPipe,
// they are completely ignored. Invalid data reaches your service unchanged.
@Post()
create(@Body() dto: CreateOrderDto) {
  return this.ordersService.create(dto);  // dto.customerId might be undefined
}
// CORRECT — Add ValidationPipe globally in main.ts (do this once, up front)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,           // Strip unknown properties
      forbidNonWhitelisted: true, // 400 if unknown properties are sent
      transform: true,           // Transform plain objects to class instances
    }),
  );
  await app.listen(3000);
}

Do this in main.ts on the first day of every project. Treat it as equivalent to the automatic validation that [ApiController] provides in .NET. Without it, your DTOs are decoration only.

Gotcha 2: Guards Cannot Access the Response Object to Short-Circuit with Custom Data

In ASP.NET Core authorization filters, you can write to the response directly:

context.Result = new JsonResult(new { error = "Custom auth error" })
{
    StatusCode = 403
};
return; // Short-circuit

In NestJS guards, you can only return true or false (or throw an exception). You cannot write a custom response body from a guard — you can only throw a ForbiddenException and let the exception filter handle the formatting.

// WRONG — Guards cannot set a custom response body
canActivate(context: ExecutionContext): boolean {
  // You cannot do this:
  // context.switchToHttp().getResponse().status(403).json({ custom: 'error' });
  return false; // This results in a generic 403 with the default error format
}

// CORRECT — Throw an exception; the exception filter formats the response
canActivate(context: ExecutionContext): boolean {
  if (!this.isAuthorized(context)) {
    throw new ForbiddenException('You do not have access to this resource');
    // Or: throw new HttpException({ custom: 'structure' }, 403);
  }
  return true;
}

Gotcha 3: Interceptors Use RxJS — Most Teams Use a Small Subset

NestJS interceptors use RxJS Observable. If you have not worked with RxJS before, this is a learning curve. The good news: in practice, you only need three or four RxJS operators for the vast majority of interceptor use cases.

import { map, tap, catchError, of, throwError } from 'rxjs';

// The four operators you'll use 95% of the time:

// map() — transform the response (like Select() in LINQ)
return next.handle().pipe(
  map((data) => ({ data, success: true })),
);

// tap() — side effect without transforming (like a callback)
return next.handle().pipe(
  tap((data) => this.cache.set(key, data)),
);

// catchError() — intercept errors (like try/catch in the response chain)
return next.handle().pipe(
  catchError((err) => {
    this.logger.error(err);
    return throwError(() => err); // Re-throw after logging
  }),
);

// of() — return an immediate value (for short-circuiting)
const cached = await this.cache.get(key);
if (cached) return of(cached);  // Return cached value, skip the handler

You do not need to understand cold vs. hot observables, subjects, multicasting, or any of RxJS’s advanced operators to write effective NestJS interceptors. Learn map, tap, catchError, and of. That is enough for years of production NestJS work.

Gotcha 4: The ExecutionContext Has to Be Switched to the Right Protocol

NestJS is designed to work across multiple protocols: HTTP, WebSockets, and gRPC. The ExecutionContext passed to guards, interceptors, and exception filters is protocol-agnostic. You must explicitly switch it to get the HTTP request/response objects:

// WRONG — switchToHttp() is required; you can't access .getRequest() directly
canActivate(context: ExecutionContext): boolean {
  const request = context.getRequest(); // This doesn't exist
  // ...
}

// CORRECT
canActivate(context: ExecutionContext): boolean {
  const request = context.switchToHttp().getRequest<Request>();
  const response = context.switchToHttp().getResponse<Response>();
  // ...
}

This feels verbose for HTTP-only applications (which is most NestJS apps). It’s designed for flexibility. Accept it and move on.

Gotcha 5: Guard Execution Order Matters, and Injected Guards Behave Differently from useGlobalGuards()

When you register a guard globally via app.useGlobalGuards(new AuthGuard()), you pass an instantiated object — the guard cannot receive injected dependencies because it’s created outside the NestJS DI context.

// WRONG — AuthGuard needs JwtService injected, but this bypasses DI
app.useGlobalGuards(new AuthGuard()); // JwtService won't be injected

// CORRECT — Register globally through the DI system using APP_GUARD
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard, // NestJS creates this through DI — all dependencies are resolved
    },
  ],
})
export class AppModule {}

The same applies to useGlobalInterceptors, useGlobalPipes, and useGlobalFilters. If your global component needs dependencies (loggers, config, database access), register it via the APP_GUARD / APP_INTERCEPTOR / APP_PIPE / APP_FILTER provider tokens in the root module instead of passing an instance to useGlobal*().

Gotcha 6: Exception Filters Must Be Ordered Correctly — More Specific First

When registering multiple exception filters, the more specific filter must come first. If a generic catch-all is registered first, it catches everything and the specific filters never run.

// WRONG — HttpExceptionFilter (generic) is registered before PrismaExceptionFilter (specific)
// PrismaClientKnownRequestError extends Error, not HttpException,
// so HttpExceptionFilter won't catch it — but the order still matters for catch-all filters
app.useGlobalFilters(
  new HttpExceptionFilter(),     // Catches HttpException
  new PrismaExceptionFilter(),   // Catches Prisma errors — this is fine in this case
);

// The real issue arises with a catch-all filter:
app.useGlobalFilters(
  new CatchAllFilter(),    // @Catch() — catches EVERYTHING — this must come LAST
  new HttpExceptionFilter(), // This will never run — CatchAllFilter intercepts everything first
);

// CORRECT — Most specific last, catch-all first... wait, no.
// Actually in NestJS, filters registered last have higher priority (innermost).
// The LAST registered global filter wraps the others.
// Put catch-all LAST:
app.useGlobalFilters(
  new HttpExceptionFilter(),     // More specific
  new PrismaExceptionFilter(),   // More specific
  // new CatchAllFilter(),       // Would need to be here if you want it to catch what the above miss
);

The priority order is counterintuitive: the last-registered global filter runs first (it’s the innermost wrapper). Check the NestJS docs for the current behavior when mixing global filters with controller/method-level filters, as the binding order interacts with scope.

Hands-On Exercise

You’re building a protected API for a multi-tenant SaaS product. Implement the following pipeline components from scratch:

1. Request ID Middleware Create middleware that attaches a unique request ID to every request (use crypto.randomUUID()) and adds it to the response headers as X-Request-ID. If the request already has an X-Request-ID header, use that value instead (for tracing across services).

2. JWT Auth Guard Create a guard that:

  • Reads the Authorization: Bearer <token> header
  • Validates the token (for this exercise, use a hardcoded SECRET_TOKEN = 'dev-secret')
  • Attaches { userId: 1, roles: ['user'] } to request.user if valid
  • Throws UnauthorizedException if the token is missing or invalid
  • Skips validation for routes decorated with @Public()

3. Timing Interceptor Create an interceptor that:

  • Records the start time before the handler runs
  • Adds an X-Response-Time: <ms>ms header to the response
  • Logs the route and timing at the DEBUG level

4. Zod Validation Pipe Implement the ZodValidationPipe from the article and wire it to a POST /items endpoint with this schema:

const CreateItemSchema = z.object({
  name: z.string().min(1).max(100),
  priceCents: z.number().int().min(0),
  sku: z.string().regex(/^[A-Z0-9-]{3,20}$/),
});

Verify that:

  • POST /items with Authorization: Bearer dev-secret and a valid body returns 201
  • POST /items without auth returns 401
  • POST /items with auth but invalid body (e.g., negative price) returns 400 with field-level errors
  • GET /health (decorated with @Public()) returns 200 without auth

Quick Reference

Pipeline Component Comparison

ComponentInterfaceDecoratorRegistered ViaASP.NET Equivalent
MiddlewareNestMiddlewareMiddlewareConsumer.apply()app.Use*()
GuardCanActivate@UseGuards()APP_GUARD or useGlobalGuards()[Authorize] / IAuthorizationFilter
InterceptorNestInterceptor@UseInterceptors()APP_INTERCEPTOR or useGlobalInterceptors()IAsyncActionFilter
PipePipeTransform@UsePipes()APP_PIPE or useGlobalPipes()Model binding + validation
Exception FilterExceptionFilter@UseFilters()APP_FILTER or useGlobalFilters()IExceptionFilter / exception middleware

Scope Registration Patterns

// Global (via main.ts — no DI injection available)
app.useGlobalGuards(new AuthGuard());
app.useGlobalInterceptors(new LoggingInterceptor());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new HttpExceptionFilter());

// Global (via module — DI injection works here — PREFERRED for components with dependencies)
@Module({
  providers: [
    { provide: APP_GUARD, useClass: AuthGuard },
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_PIPE, useClass: ValidationPipe },
    { provide: APP_FILTER, useClass: HttpExceptionFilter },
  ],
})
export class AppModule {}

// Controller level
@Controller('orders')
@UseGuards(AuthGuard)
@UseInterceptors(LoggingInterceptor)
export class OrdersController { /* ... */ }

// Method level
@Post()
@UseGuards(RolesGuard)
@UseInterceptors(CacheInterceptor)
@UsePipes(new ZodValidationPipe(CreateOrderSchema))
create(@Body() dto: CreateOrderDto) { /* ... */ }

Built-in Pipes Reference

ParseIntPipe        // string → number (integer)
ParseFloatPipe      // string → number (float)
ParseBoolPipe       // 'true'/'false' → boolean
ParseArrayPipe      // comma-separated string → array
ParseUUIDPipe       // validates UUID format
ParseEnumPipe       // validates enum membership
DefaultValuePipe    // provides default if value is undefined
ValidationPipe      // runs class-validator decorators

Built-in HTTP Exceptions Reference

BadRequestException          // 400
UnauthorizedException        // 401
ForbiddenException           // 403
NotFoundException             // 404
MethodNotAllowedException     // 405
NotAcceptableException        // 406
RequestTimeoutException       // 408
ConflictException             // 409
GoneException                 // 410
PayloadTooLargeException      // 413
UnsupportedMediaTypeException // 415
UnprocessableEntityException  // 422
InternalServerErrorException  // 500
NotImplementedException       // 501
BadGatewayException           // 502
ServiceUnavailableException   // 503
GatewayTimeoutException       // 504

RxJS Operators Used in Interceptors

map((data) => transform(data))           // Transform the response value
tap((data) => sideEffect(data))          // Side effect; doesn't change the value
catchError((err) => handleError(err))    // Intercept errors; must return Observable
throwError(() => new Error('...'))       // Re-throw or throw new error
of(value)                                // Emit an immediate value (for cache hits)

Further Reading