NestJS Interview Questions: Enterprise Node.js Framework Guide

·15 min read
nestjsinterview-questionsnodejstypescriptbackenddependency-injection

NestJS has become the go-to framework for enterprise Node.js applications, yet many candidates struggle to explain why it exists or how its dependency injection actually works. The question "Why NestJS over Express?" separates developers who've just followed tutorials from those who understand architectural decisions. Here's how to demonstrate that understanding.

The 30-Second Answer

When the interviewer asks "What is NestJS and when would you use it?", here's your concise answer:

"NestJS is an opinionated Node.js framework that brings Angular-like architecture to the backend. It provides structure through modules, built-in dependency injection, and decorators for everything from routing to validation. I'd use it over Express for larger teams or enterprise applications where consistent architecture and testability matter more than minimal setup. It's TypeScript-first and has excellent support for microservices, GraphQL, and WebSockets out of the box."

That's it. Wait for follow-up questions.

The 2-Minute Answer (If They Want More)

If they ask you to elaborate:

"Express is minimal and unopinionated—you decide everything from folder structure to how you handle dependency injection. That's great for small projects or when you want maximum flexibility.

NestJS provides opinions and structure:

  • Modules organize code by feature, not by type
  • Dependency injection is built-in, making testing easier
  • Decorators handle routing, validation, and cross-cutting concerns
  • First-class TypeScript with full type safety

The tradeoff is more boilerplate and a steeper learning curve. But for teams of 5+ developers or applications that will grow over years, that structure pays dividends.

Under the hood, NestJS uses Express (or optionally Fastify) as its HTTP layer. So you're not losing Express—you're adding architecture on top of it."

Core Concepts

Modules

Modules are the fundamental organizational unit in NestJS:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
 
@Module({
  imports: [TypeOrmModule.forFeature([User])],  // Import dependencies
  controllers: [UsersController],               // Handle HTTP requests
  providers: [UsersService],                    // Business logic & DI
  exports: [UsersService]                       // Make available to other modules
})
export class UsersModule {}
// app.module.ts (root module)
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { TypeOrmModule } from '@nestjs/typeorm';
 
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      // ... config
    }),
    UsersModule,
    AuthModule,
  ],
})
export class AppModule {}

Key points:

  • Each module encapsulates a feature (users, auth, orders)
  • imports bring in other modules' exported providers
  • exports make providers available to importing modules
  • Providers are module-scoped by default

Controllers

Controllers handle incoming HTTP requests:

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
 
@Controller('users')  // Route prefix: /users
export class UsersController {
  // Dependency injection via constructor
  constructor(private readonly usersService: UsersService) {}
 
  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
 
  @Get()
  findAll(@Query('role') role?: string) {
    return this.usersService.findAll(role);
  }
 
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
 
  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto);
  }
 
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.remove(id);
  }
}

Compare to Express:

// Express
router.get('/users/:id', (req, res) => {
  const id = parseInt(req.params.id);  // Manual parsing
  // ...
});
 
// NestJS - ParseIntPipe handles parsing and validation
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { }

Services and Dependency Injection

Services contain business logic and are injected where needed:

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
 
@Injectable()  // Marks class for DI container
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}
 
  async create(createUserDto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create(createUserDto);
    return this.usersRepository.save(user);
  }
 
  async findAll(role?: string): Promise<User[]> {
    if (role) {
      return this.usersRepository.find({ where: { role } });
    }
    return this.usersRepository.find();
  }
 
  async findOne(id: number): Promise<User> {
    const user = await this.usersRepository.findOne({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User #${id} not found`);
    }
    return user;
  }
 
  async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
    await this.usersRepository.update(id, updateUserDto);
    return this.findOne(id);
  }
 
  async remove(id: number): Promise<void> {
    const result = await this.usersRepository.delete(id);
    if (result.affected === 0) {
      throw new NotFoundException(`User #${id} not found`);
    }
  }
}

Interview insight: "The @Injectable() decorator tells NestJS this class can be managed by the IoC container. When UsersController needs UsersService, NestJS looks at the constructor parameter types, finds the matching provider, and injects an instance. This is constructor injection—the most common DI pattern."

Dependency Injection Deep Dive

How DI Works

// 1. Mark class as injectable
@Injectable()
export class CatsService {
  constructor(private readonly logger: LoggerService) {}
}
 
// 2. Register as provider in module
@Module({
  providers: [CatsService, LoggerService],
})
export class CatsModule {}
 
// 3. Inject via constructor
@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}
}

Provider Scopes

import { Injectable, Scope } from '@nestjs/common';
 
// DEFAULT - Singleton (one instance shared across app)
@Injectable()
export class SingletonService {}
 
// REQUEST - New instance per request
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {}
 
// TRANSIENT - New instance each time injected
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {}

When to use each:

  • Singleton (default): Most services—stateless business logic
  • Request: When you need request-specific data (current user, tenant)
  • Transient: When each consumer needs its own instance (rare)

Custom Providers

@Module({
  providers: [
    // Standard provider
    UsersService,
 
    // Value provider
    {
      provide: 'API_KEY',
      useValue: process.env.API_KEY,
    },
 
    // Factory provider
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: async (configService: ConfigService) => {
        return createConnection(configService.get('database'));
      },
      inject: [ConfigService],
    },
 
    // Class provider with different implementation
    {
      provide: LoggerService,
      useClass: process.env.NODE_ENV === 'test'
        ? MockLoggerService
        : ProductionLoggerService,
    },
  ],
})
export class AppModule {}
 
// Inject custom provider
@Injectable()
export class SomeService {
  constructor(@Inject('API_KEY') private apiKey: string) {}
}

Validation with DTOs and Pipes

DTOs with class-validator

// dto/create-user.dto.ts
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MinLength,
  IsOptional,
  IsEnum,
} from 'class-validator';
import { Transform } from 'class-transformer';
 
export enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
}
 
export class CreateUserDto {
  @IsNotEmpty()
  @IsString()
  @Transform(({ value }) => value.trim())
  name: string;
 
  @IsEmail()
  @Transform(({ value }) => value.toLowerCase())
  email: string;
 
  @IsString()
  @MinLength(8, { message: 'Password must be at least 8 characters' })
  password: string;
 
  @IsOptional()
  @IsEnum(UserRole)
  role?: UserRole = UserRole.USER;
}
 
// dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
 
// All fields optional, same validation rules
export class UpdateUserDto extends PartialType(CreateUserDto) {}

Global Validation Pipe

// main.ts
import { ValidationPipe } from '@nestjs/common';
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
 
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,           // Strip non-whitelisted properties
    forbidNonWhitelisted: true, // Throw error on extra properties
    transform: true,            // Auto-transform payloads to DTO types
    transformOptions: {
      enableImplicitConversion: true,
    },
  }));
 
  await app.listen(3000);
}

Custom Pipes

import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
 
@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
  transform(value: string): Date {
    const date = new Date(value);
    if (isNaN(date.getTime())) {
      throw new BadRequestException(`Invalid date: ${value}`);
    }
    return date;
  }
}
 
// Usage
@Get()
findByDate(@Query('date', ParseDatePipe) date: Date) {
  return this.service.findByDate(date);
}

Guards (Authorization)

Guards determine if a request should proceed:

// auth/guards/jwt-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
 
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractToken(request);
 
    if (!token) {
      return false;
    }
 
    try {
      const payload = await this.jwtService.verifyAsync(token);
      request.user = payload;
      return true;
    } catch {
      return false;
    }
  }
 
  private extractToken(request: any): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}
 
// auth/guards/roles.guard.ts
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 {
    const requiredRoles = this.reflector.get<string[]>(
      'roles',
      context.getHandler(),
    );
 
    if (!requiredRoles) {
      return true;  // No roles required
    }
 
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.includes(user.role);
  }
}
 
// auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
 
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

Using Guards

@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)  // Apply to all routes in controller
export class AdminController {
 
  @Get('dashboard')
  @Roles('admin')  // Only admins
  getDashboard() {
    return { message: 'Admin dashboard' };
  }
 
  @Get('stats')
  @Roles('admin', 'moderator')  // Admins and moderators
  getStats() {
    return { message: 'Stats' };
  }
}
 
// Or apply globally
// app.module.ts
@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

Interceptors

Interceptors wrap handler execution for cross-cutting concerns:

// interceptors/logging.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
 
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const method = request.method;
    const url = request.url;
    const now = Date.now();
 
    return next.handle().pipe(
      tap(() => {
        console.log(`${method} ${url} - ${Date.now() - now}ms`);
      }),
    );
  }
}
 
// interceptors/transform.interceptor.ts
import { map } from 'rxjs/operators';
 
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}
 
// interceptors/cache.interceptor.ts
@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, any>();
 
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const key = request.url;
 
    if (this.cache.has(key)) {
      return of(this.cache.get(key));
    }
 
    return next.handle().pipe(
      tap(response => this.cache.set(key, response)),
    );
  }
}

Exception Filters

Handle exceptions and format error responses:

// filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
 
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
 
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
 
    const message =
      exception instanceof HttpException
        ? exception.message
        : 'Internal server error';
 
    response.status(status).json({
      success: false,
      statusCode: status,
      message,
      path: request.url,
      timestamp: new Date().toISOString(),
    });
  }
}
 
// Apply globally
app.useGlobalFilters(new AllExceptionsFilter());

Custom Exceptions

import { HttpException, HttpStatus } from '@nestjs/common';
 
export class UserNotFoundException extends HttpException {
  constructor(userId: number) {
    super(`User with ID ${userId} not found`, HttpStatus.NOT_FOUND);
  }
}
 
export class InsufficientPermissionsException extends HttpException {
  constructor() {
    super('Insufficient permissions', HttpStatus.FORBIDDEN);
  }
}
 
// Usage
throw new UserNotFoundException(userId);

Request Lifecycle

Understanding execution order is a common interview topic:

Incoming Request
      │
      ▼
  Middleware
      │
      ▼
    Guards ──────────▶ (return false = 403)
      │
      ▼
  Interceptors (before)
      │
      ▼
    Pipes ───────────▶ (validation fails = 400)
      │
      ▼
   Handler (Controller method)
      │
      ▼
  Interceptors (after)
      │
      ▼
Exception Filters (if error thrown)
      │
      ▼
    Response

Interview insight: "Middleware runs first, then Guards decide if the request should proceed. Interceptors wrap the handler and can modify both request and response. Pipes validate and transform input. If anything throws, Exception Filters catch it."

Database Integration

TypeORM Example

// entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
 
@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column()
  name: string;
 
  @Column({ unique: true })
  email: string;
 
  @Column({ select: false })  // Exclude from queries by default
  password: string;
 
  @Column({ default: 'user' })
  role: string;
 
  @CreateDateColumn()
  createdAt: Date;
}
 
// users.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Prisma Example

// prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
 
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }
 
  async onModuleDestroy() {
    await this.$disconnect();
  }
}
 
// users.service.ts
@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}
 
  async findAll() {
    return this.prisma.user.findMany();
  }
 
  async findOne(id: number) {
    return this.prisma.user.findUnique({ where: { id } });
  }
}

Testing

NestJS has excellent testing support:

// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
 
describe('UsersService', () => {
  let service: UsersService;
  let mockRepository: any;
 
  beforeEach(async () => {
    mockRepository = {
      find: jest.fn(),
      findOne: jest.fn(),
      create: jest.fn(),
      save: jest.fn(),
    };
 
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: mockRepository,
        },
      ],
    }).compile();
 
    service = module.get<UsersService>(UsersService);
  });
 
  it('should find all users', async () => {
    const users = [{ id: 1, name: 'John' }];
    mockRepository.find.mockResolvedValue(users);
 
    expect(await service.findAll()).toEqual(users);
    expect(mockRepository.find).toHaveBeenCalled();
  });
 
  it('should throw NotFoundException for missing user', async () => {
    mockRepository.findOne.mockResolvedValue(null);
 
    await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
  });
});
 
// users.controller.spec.ts (e2e style)
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
 
describe('UsersController (e2e)', () => {
  let app: INestApplication;
 
  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
 
    app = moduleRef.createNestApplication();
    await app.init();
  });
 
  it('/users (GET)', () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200)
      .expect((res) => {
        expect(Array.isArray(res.body)).toBe(true);
      });
  });
 
  afterAll(async () => {
    await app.close();
  });
});

Common Interview Questions

"How is NestJS different from Express?"

"Express is minimal and gives you complete freedom—you decide folder structure, middleware patterns, and how to organize code. NestJS is opinionated, providing modules for organization, built-in dependency injection, decorators for routing and validation, and a consistent architecture.

The tradeoff is that NestJS has more boilerplate, but it scales better for larger teams. Under the hood, NestJS uses Express (or Fastify) as its HTTP layer—you're adding architecture, not replacing the runtime."

"Explain the module system"

"Modules are the core organizational unit. Each module encapsulates related functionality—controllers, services, entities. The root AppModule imports feature modules. Modules can import other modules to use their exported providers. This creates clear boundaries and makes it easy to understand dependencies at a glance."

"When would you NOT use NestJS?"

"For simple APIs or quick prototypes, Express is faster to set up. For serverless functions that need minimal cold start time, the NestJS overhead might be too much. For teams unfamiliar with Angular-style architecture or TypeScript, the learning curve could slow initial development."

"How do you handle configuration?"

// Using @nestjs/config
import { ConfigModule, ConfigService } from '@nestjs/config';
 
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `.env.${process.env.NODE_ENV}`,
    }),
  ],
})
export class AppModule {}
 
// Inject and use
@Injectable()
export class SomeService {
  constructor(private configService: ConfigService) {}
 
  getDatabaseUrl() {
    return this.configService.get<string>('DATABASE_URL');
  }
}

Quick Reference

ConceptDecorator/ClassPurpose
Module@Module()Organize code, define boundaries
Controller@Controller()Handle HTTP requests
Service@Injectable()Business logic, DI-managed
GuardCanActivateAuthorization, access control
PipePipeTransformValidation, transformation
InterceptorNestInterceptorWrap handler, modify request/response
FilterExceptionFilterHandle exceptions
MiddlewareNestMiddlewarePre-route logic (like Express)

NestJS vs Express Comparison

AspectExpressNestJS
StructureYou decideModules, controllers, services
DIManual or external libBuilt-in IoC container
TypeScriptOptionalFirst-class support
ValidationManual or middlewareValidationPipe + class-validator
TestingManual setupTest module with DI mocking
Learning curveLowerHigher
Best forSmall/medium APIs, prototypesEnterprise, large teams

Common Mistakes to Avoid

  1. Circular dependencies - Use forwardRef() or restructure modules
  2. Not using DTOs - Always validate input with class-validator
  3. Business logic in controllers - Keep controllers thin, logic in services
  4. Ignoring scopes - Understand singleton vs request-scoped implications
  5. Not leveraging DI for testing - Mock providers, don't test with real dependencies
  6. Forgetting to export providers - If another module needs it, export it

Related Articles

If you found this helpful, check out these related guides:

Ready for More Backend Interview Questions?

This is just one topic from our complete backend interview prep guide. Get access to 50+ questions covering:

  • Advanced NestJS patterns (microservices, CQRS)
  • GraphQL with NestJS
  • Testing strategies
  • Performance optimization
  • Deployment and DevOps

Get Full Access to All Backend Questions


Written by the EasyInterview team, based on real interview experience from 12+ years in tech and hundreds of technical interviews conducted at companies like BNY Mellon, UBS, and leading fintech firms.

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides