NestJS Interview: Guards, Interceptors and Modular Architecture

Common NestJS interview questions on Guards, Interceptors and modular architecture, with concrete TypeScript code examples and technical explanations.

NestJS Guards Interceptors and modular architecture for technical interviews

NestJS technical interviews consistently focus on three framework pillars: Guards, Interceptors, and modular architecture. These mechanisms form the backbone of any well-structured NestJS application, and hiring managers use them to assess real-world framework proficiency.

What interviewers evaluate

A Guard controls access (who gets in), an Interceptor transforms data (what goes in and out), and modular architecture organizes everything. Mastering all three demonstrates deep NestJS understanding beyond basic CRUD operations.

How NestJS Guards work and common interview scenarios

Guards implement the CanActivate interface and determine whether a request reaches the handler. Unlike middleware, Guards access the NestJS execution context (ExecutionContext), enabling authorization decisions based on handler metadata.

Classic question: "Implement a Guard that checks user roles."

roles.guard.tstypescript
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

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

  canActivate(context: ExecutionContext): boolean {
    // Retrieve roles defined via the @Roles() decorator
    const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);

    // No roles required means the route is public
    if (!requiredRoles) return true;

    // Extract user from the HTTP request
    const { user } = context.switchToHttp().getRequest();

    // Check if the user holds at least one required role
    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

The Reflector reads metadata attached by custom decorators. The companion @Roles() decorator looks like this:

roles.decorator.tstypescript
import { SetMetadata } from '@nestjs/common';

// Creates a decorator that attaches roles as metadata
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

A strong interview answer goes beyond the code. The execution order matters: Middleware → Guards → Interceptors (before) → Pipes → Handler → Interceptors (after) → Exception Filters. This sequence appears in virtually every NestJS interview.

NestJS Interceptors: transforming requests and responses

Interceptors implement NestInterceptor and use RxJS to manipulate the data stream before and after the handler. Their power comes from access to CallHandler, which represents the execution pipeline.

Frequent question: "Create a logging Interceptor that measures execution time."

logging.interceptor.tstypescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const now = Date.now();

    // next.handle() invokes the route handler
    return next.handle().pipe(
      tap(() => {
        // Runs AFTER the handler responds
        const duration = Date.now() - now;
        this.logger.log(`${method} ${url}${duration}ms`);
      }),
    );
  }
}

The RxJS tap operator observes the stream without modifying it. To transform the response, map is the right operator:

transform.interceptor.tstypescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

// Generic interface for a standardized API response
interface ApiResponse<T> {
  data: T;
  timestamp: string;
  path: string;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
    const request = context.switchToHttp().getRequest();

    return next.handle().pipe(
      map((data) => ({
        data,
        timestamp: new Date().toISOString(),
        path: request.url,
      })),
    );
  }
}

This standardized response pattern scores well in interviews: it shows the ability to think about API design and response consistency.

Guard vs Interceptor vs Middleware

Middleware: raw HTTP processing (like Express). Guard: boolean access decision. Interceptor: request/response stream transformation with RxJS. Confusing these three concepts in an interview is an immediate red flag.

NestJS modular architecture: building scalable applications

Modular architecture separates junior from senior developers in interviews. NestJS enforces module-based organization, but the real questions target architectural decisions: when to create a module, and how to manage inter-module dependencies.

Advanced question: "How would you structure an e-commerce NestJS application with properly decoupled modules?"

orders/orders.module.tstypescript
import { Module } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
import { PaymentsModule } from '../payments/payments.module';
import { ProductsModule } from '../products/products.module';

@Module({
  // Import modules that OrdersModule depends on
  imports: [PaymentsModule, ProductsModule],
  controllers: [OrdersController],
  providers: [OrdersService],
  // Expose OrdersService to modules that import OrdersModule
  exports: [OrdersService],
})
export class OrdersModule {}

The classic interview trap: circular dependencies. If OrdersModule imports PaymentsModule and PaymentsModule imports OrdersModule, NestJS fails at startup. The escape hatch uses forwardRef:

typescript
// Resolving circular dependencies
@Module({
  imports: [forwardRef(() => PaymentsModule)],
})
export class OrdersModule {}

The strongest interview answer explains that forwardRef is a workaround, not a solution. The proper fix is refactoring to extract a shared module (SharedOrderPaymentModule) or using an Event Emitter to decouple communications.

Ready to ace your Node.js / NestJS interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Dynamic Modules and advanced configuration

Dynamic Modules create configurable, reusable modules across projects. This pattern appears in senior-level interviews.

Question: "Create a configurable cache module with dynamic options."

cache/cache.module.tstypescript
import { Module, DynamicModule, Global } from '@nestjs/common';
import { CacheService } from './cache.service';

// Module configuration interface
export interface CacheModuleOptions {
  ttl: number;        // Time-to-live in seconds
  maxItems: number;   // Maximum number of entries
  prefix: string;     // Key prefix
}

@Global() // Available everywhere without explicit import
@Module({})
export class CacheModule {
  static forRoot(options: CacheModuleOptions): DynamicModule {
    return {
      module: CacheModule,
      providers: [
        {
          // Inject options via a custom token
          provide: 'CACHE_OPTIONS',
          useValue: options,
        },
        CacheService,
      ],
      exports: [CacheService],
    };
  }
}

The corresponding service injects options via @Inject():

cache/cache.service.tstypescript
import { Injectable, Inject } from '@nestjs/common';
import { CacheModuleOptions } from './cache.module';

@Injectable()
export class CacheService {
  private store = new Map<string, { value: any; expiry: number }>();

  constructor(@Inject('CACHE_OPTIONS') private options: CacheModuleOptions) {}

  set(key: string, value: any): void {
    const prefixedKey = `${this.options.prefix}:${key}`;
    this.store.set(prefixedKey, {
      value,
      expiry: Date.now() + this.options.ttl * 1000,
    });
  }

  get<T>(key: string): T | null {
    const prefixedKey = `${this.options.prefix}:${key}`;
    const entry = this.store.get(prefixedKey);
    // Check expiration before returning
    if (!entry || entry.expiry < Date.now()) return null;
    return entry.value as T;
  }
}

The forRoot / forRootAsync pattern is a NestJS staple. forRootAsync loads configuration from an injectable ConfigService, which is the recommended production practice.

Interview trap: @Global()

Marking a module @Global() seems convenient, but overusing it creates invisible coupling. Mentioning in an interview that @Global() should be reserved for cross-cutting services (config, cache, logger) demonstrates architectural maturity.

Custom Decorators and Guard composition

Custom decorators combine multiple Guards and metadata into a single annotation. This topic comes up for mid-senior positions.

auth.decorator.tstypescript
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';

// Combines JWT authentication + role verification
export function Auth(...roles: string[]) {
  return applyDecorators(
    SetMetadata('roles', roles),
    UseGuards(JwtAuthGuard, RolesGuard),
  );
}

Usage in a controller:

orders/orders.controller.tstypescript
import { Controller, Get, Post, Body } from '@nestjs/common';
import { Auth } from '../auth/auth.decorator';
import { OrdersService } from './orders.service';

@Controller('orders')
export class OrdersController {
  constructor(private ordersService: OrdersService) {}

  @Get()
  @Auth('admin', 'manager') // Single decorator replaces UseGuards + Roles
  findAll() {
    return this.ordersService.findAll();
  }

  @Post()
  @Auth('admin')
  create(@Body() dto: CreateOrderDto) {
    return this.ordersService.create(dto);
  }
}

The applyDecorators pattern simplifies controller code and centralizes authorization logic. Proactively suggesting this approach in an interview is a strong signal.

Conclusion

  • Guards use CanActivate and the Reflector for access decisions based on handler metadata
  • Interceptors leverage RxJS (tap, map) to transform the stream before and after the handler
  • The execution order Middleware → Guards → Interceptors → Pipes → Handler is a near-universal question
  • Circular dependencies should be resolved through architectural refactoring, not forwardRef
  • Dynamic Modules (forRoot/forRootAsync) demonstrate the ability to build reusable components
  • applyDecorators composes multiple Guards into a single, readable decorator
  • Practice these questions with real code on NestJS modules and Interceptors

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#nestjs
#guards
#interceptors
#architecture
#interview

Share

Related articles