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)
importsbring in other modules' exported providersexportsmake 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
| Concept | Decorator/Class | Purpose |
|---|---|---|
| Module | @Module() | Organize code, define boundaries |
| Controller | @Controller() | Handle HTTP requests |
| Service | @Injectable() | Business logic, DI-managed |
| Guard | CanActivate | Authorization, access control |
| Pipe | PipeTransform | Validation, transformation |
| Interceptor | NestInterceptor | Wrap handler, modify request/response |
| Filter | ExceptionFilter | Handle exceptions |
| Middleware | NestMiddleware | Pre-route logic (like Express) |
NestJS vs Express Comparison
| Aspect | Express | NestJS |
|---|---|---|
| Structure | You decide | Modules, controllers, services |
| DI | Manual or external lib | Built-in IoC container |
| TypeScript | Optional | First-class support |
| Validation | Manual or middleware | ValidationPipe + class-validator |
| Testing | Manual setup | Test module with DI mocking |
| Learning curve | Lower | Higher |
| Best for | Small/medium APIs, prototypes | Enterprise, large teams |
Common Mistakes to Avoid
- Circular dependencies - Use
forwardRef()or restructure modules - Not using DTOs - Always validate input with class-validator
- Business logic in controllers - Keep controllers thin, logic in services
- Ignoring scopes - Understand singleton vs request-scoped implications
- Not leveraging DI for testing - Mock providers, don't test with real dependencies
- Forgetting to export providers - If another module needs it, export it
Related Articles
If you found this helpful, check out these related guides:
- Complete Node.js Backend Developer Interview Guide - comprehensive preparation guide for backend interviews
- Express.js Middleware Interview Guide - Compare Express patterns to NestJS
- TypeScript Generics Interview Guide - TypeScript patterns used heavily in NestJS
- Authentication & JWT Interview Guide - Auth patterns for NestJS apps
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.
