NestJS interview questions covering modules, controllers, providers, dependency injection, guards, pipes, interceptors, microservices, and testing.
NestJS is a TypeScript-first Node.js framework for building scalable server-side applications. It uses concepts from Angular, such as modules, controllers, providers, decorators, dependency injection, guards, pipes, and interceptors. Under the hood it can run on Express or Fastify. A strong interview answer should mention that NestJS adds structure and architecture on top of the lower-level Node HTTP ecosystem.
A team chooses NestJS when it wants an opinionated architecture, dependency injection, TypeScript patterns, testable modules, decorators, validation, guards, interceptors, and built-in support for GraphQL, microservices, WebSockets, and OpenAPI. Express is simpler and more flexible, but large teams often benefit from NestJS conventions because code organization stays predictable as the project grows.
A module is a class decorated with @Module() that groups related controllers, providers, imports, and exports. Modules define boundaries in the application. For example, a UsersModule can contain UsersController and UsersService, export UsersService, and import DatabaseModule when it needs persistence.
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
The root module, usually AppModule, is the top-level module used to bootstrap the application. It imports feature modules and global infrastructure modules such as ConfigModule, DatabaseModule, AuthModule, or LoggerModule. It should not become a place for all business logic; it is mainly the composition root.
A controller handles incoming HTTP requests and returns responses. Controllers map routes using decorators such as @Controller(), @Get(), @Post(), @Patch(), and @Delete(). They should focus on HTTP concerns and delegate business logic to providers or services.
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
}
A provider is a class or value managed by the NestJS dependency injection container. Services, repositories, factories, guards, pipes, and interceptors can all be providers. Providers let NestJS create instances, resolve dependencies, and inject them where needed.
NestJS uses constructor injection by default. When a provider is listed in a module, Nest can instantiate it and inject its dependencies. This improves testability because tests can replace real providers with mocks. It also helps separate controllers, services, repositories, and infrastructure clients.
@Injectable()
export class OrdersService {
constructor(private readonly paymentService: PaymentService) {}
async createOrder(dto: CreateOrderDto) {
return this.paymentService.charge(dto.paymentToken, dto.amount);
}
}
@Injectable() marks a class as a provider that can participate in NestJS dependency injection. It also enables Nest metadata reflection for dependencies. Services, custom validators, guards, interceptors, and repositories commonly use @Injectable().
Custom providers let you define how a dependency is created or resolved. They are useful for injecting configuration values, external clients, factories, aliases, and different implementations for the same interface-like token.
export const CACHE_CLIENT = 'CACHE_CLIENT';
@Module({
providers: [
{
provide: CACHE_CLIENT,
useFactory: () => new RedisClient(process.env.REDIS_URL),
},
],
exports: [CACHE_CLIENT],
})
export class CacheModule {}
Provider scope controls instance lifetime. The default singleton scope creates one instance per application. Request scope creates one instance per request, which is useful for request-specific context but more expensive. Transient scope creates a new instance for each injection. In interviews, mention that request-scoped providers can hurt performance if used casually.
A DTO, or Data Transfer Object, defines the shape of incoming or outgoing data. In NestJS, DTO classes often work with class-validator and class-transformer so incoming requests can be validated and transformed before reaching service logic.
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
}
Pipes transform or validate input before it reaches the route handler. Built-in pipes include ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, and ValidationPipe. Custom pipes can enforce application-specific parsing or validation rules.
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
Enable ValidationPipe globally in main.ts so DTO validation applies consistently. Common production options include whitelist to remove unknown fields, forbidNonWhitelisted to reject unexpected fields, and transform to convert payloads into DTO class instances.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.listen(3000);
}
Guards decide whether a request should continue to a route handler. They are commonly used for authentication, authorization, feature flags, tenant access, and role checks. A guard returns true to allow the request or false/throws an exception to deny it.
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return Boolean(request.user);
}
}
Middleware runs before route matching details are fully handled and is good for request logging, raw request mutation, cookie parsing, or attaching basic context. Guards run after middleware and before route handlers with access to ExecutionContext and route metadata, making them better for authentication and authorization decisions.
Interceptors wrap route execution. They can transform responses, add logging, measure latency, map exceptions, cache results, or add cross-cutting behavior before and after handlers run. Interceptors are useful when behavior belongs around a handler rather than inside the handler.
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const started = Date.now();
return next.handle().pipe(
tap(() => console.log('Request took ' + (Date.now() - started) + 'ms'))
);
}
}
Exception filters customize how thrown errors become HTTP responses. Nest has built-in handling for HttpException, but filters are useful for consistent API error shapes, mapping database errors, hiding sensitive details, and adding request IDs to error responses.
@Catch(NotFoundException)
export class NotFoundFilter implements ExceptionFilter {
catch(exception: NotFoundException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
response.status(404).json({ error: 'Resource not found' });
}
}
A typical HTTP request flows through middleware, guards, interceptors before the handler, pipes, controller method, service logic, interceptors after the handler, and exception filters if an error is thrown. Understanding this order helps debug why validation, auth, logging, or error formatting is not running as expected.
Custom decorators extract reusable metadata or request values. For example, a @CurrentUser() decorator can read request.user without repeating request extraction in every controller. Decorators make controllers cleaner, but they should not hide complex business logic.
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
JWT authentication is usually built with Passport, JwtStrategy, AuthGuard, and an AuthService that signs tokens after verifying credentials. The strategy validates token payloads, while guards protect routes. Production designs should handle expiration, refresh tokens, revocation, and secret rotation.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
});
}
validate(payload: { sub: string; email: string }) {
return { id: payload.sub, email: payload.email };
}
}
Roles and permissions are commonly implemented with metadata decorators plus a guard that reads the metadata through Reflector. Roles are simple but can become too coarse. Permissions or policy-based authorization works better for complex domains with ownership, tenants, and resource-level rules.
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(id);
}
ConfigModule centralizes environment-based configuration. It can load .env files, validate environment variables, and expose ConfigService to providers. A production app should validate required settings at startup so configuration errors are caught before serving traffic.
Dynamic modules are modules that can be configured at import time. They usually expose static methods such as forRoot() or forFeature(). They are useful for reusable infrastructure modules like database, cache, messaging, storage, and SDK clients.
@Module({})
export class StorageModule {
static forRoot(bucket: string): DynamicModule {
return {
module: StorageModule,
providers: [{ provide: 'BUCKET', useValue: bucket }],
exports: ['BUCKET'],
};
}
}
forRoot() usually configures a module once at application level, such as database connection settings. forFeature() usually registers feature-specific providers, such as repositories or models needed by one module. This pattern is common in TypeORM, Mongoose, GraphQL, and custom infrastructure modules.
A common pattern is to create a PrismaService that extends PrismaClient and is registered as a provider. Services inject PrismaService and use it for database access. In production, handle connection lifecycle, logging, migrations, transaction strategy, and testing with isolated databases or mocks.
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.user.findMany();
}
}
TypeORM integration usually uses TypeOrmModule.forRoot() for database connection configuration and TypeOrmModule.forFeature() for repositories used by feature modules. Services inject repositories with @InjectRepository(). Important interview points include migrations, connection pooling, transactions, and avoiding entity leakage into API contracts.
Transactions should be handled close to the service method that owns the business operation. Avoid spreading one transaction across unrelated layers. With Prisma, use prisma.$transaction. With TypeORM, use a query runner or transaction manager. Tests should cover rollback behavior for partial failures.
NestJS supports GraphQL through code-first or schema-first approaches. Code-first uses decorators on resolver classes and DTO/object types. Resolvers are similar to controllers but map GraphQL queries and mutations instead of HTTP routes. Watch for N+1 query issues and use DataLoader when needed.
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => [User])
users() {
return this.usersService.findAll();
}
}
NestJS microservices let services communicate over transports such as TCP, Redis, NATS, Kafka, RabbitMQ, or gRPC. They use message patterns instead of HTTP route decorators. Microservices are useful when teams need independent scaling or async workflows, but they add operational complexity, tracing needs, and failure modes.
@MessagePattern() subscribes a handler to a message pattern in a NestJS microservice. It is similar to a route decorator, but for transport messages. The handler should validate payloads, be idempotent when appropriate, and return clear responses or emit events depending on the pattern.
@Controller()
export class OrdersMessageController {
@MessagePattern('order.created')
handleOrderCreated(data: CreateOrderMessage) {
return this.ordersService.processCreatedOrder(data);
}
}
NestJS supports WebSockets through gateways. A gateway handles socket connections, subscriptions, and emitted events. Use guards or middleware-like logic for socket authentication, and plan for scaling with adapters such as Redis when multiple application instances need to broadcast events.
@WebSocketGateway()
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(@MessageBody() body: string) {
return { event: 'message', data: body };
}
}
CQRS separates commands that change state from queries that read state. NestJS provides @nestjs/cqrs for commands, handlers, events, and sagas. It can make complex domains clearer, but it is overkill for simple CRUD applications. Use it when business workflows, events, or write/read models justify the complexity.
Event emitters are useful for decoupling side effects from core operations. For example, after user registration, the app can emit a user.created event and listeners can send welcome emails or analytics. Be careful with reliability: in-memory events are not a durable message queue.
Scheduled tasks use ScheduleModule and decorators such as @Cron(), @Interval(), and @Timeout(). They are useful for cleanup jobs, reminders, syncs, and reports. In multi-instance deployments, prevent duplicate execution with leader election, queues, or distributed locks.
@Injectable()
export class ReportsJob {
@Cron('0 2 * * *')
generateDailyReport() {
return this.reportsService.generateDailyReport();
}
}
Use TestingModule to create the service with mocked dependencies. Unit tests should focus on business behavior and edge cases without starting an HTTP server or connecting to real external services.
const moduleRef = await Test.createTestingModule({
providers: [
UsersService,
{ provide: PrismaService, useValue: prismaMock },
],
}).compile();
const service = moduleRef.get(UsersService);
E2E tests boot a Nest application and send real HTTP requests, often with Supertest. They should verify routing, validation, guards, serialization, and error formatting together. Use test databases or isolated containers for flows that touch persistence.
const app = moduleFixture.createNestApplication();
await app.init();
await request(app.getHttpServer())
.get('/health')
.expect(200)
.expect({ status: 'ok' });
Use @nestjs/swagger decorators and SwaggerModule. DTOs, controllers, response decorators, and auth decorators can generate an OpenAPI document. Keep docs close to the code, but verify generated schemas because runtime validation and documentation can drift if decorators are incomplete.
const config = new DocumentBuilder()
.setTitle('Users API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
Serialization controls what data is returned to clients. ClassSerializerInterceptor and class-transformer can hide fields such as passwords or internal IDs. Do not rely only on serialization for security; services should avoid returning sensitive data when possible.
NestJS can use Multer interceptors for multipart uploads. Validate file size and type, avoid trusting original filenames, store files outside the application instance when needed, and return safe metadata rather than raw file system paths.
@Post('avatar')
@UseInterceptors(FileInterceptor('avatar'))
uploadAvatar(@UploadedFile() file: Express.Multer.File) {
return { filename: file.originalname, size: file.size };
}
Enable CORS in main.ts with a restricted origin list, allowed methods, and credentials only when required. Avoid wildcard origins for authenticated browser APIs. CORS is a browser security policy, so server-to-server requests are not blocked by it.
app.enableCors({
origin: ['https://example.com', 'https://admin.example.com'],
credentials: true,
});
NestJS includes a Logger class and also supports custom loggers. Production systems often use structured logging with request IDs, user or tenant context when safe, status codes, latency, and error causes. Interceptors are a good place for request timing logs.
Use @nestjs/terminus or a simple controller endpoint. Liveness checks show the process is running, while readiness checks verify dependencies such as the database, cache, or queue. Keep health checks fast because orchestrators call them frequently.
NestJS supports URI, header, media type, and custom versioning strategies. URI versioning such as /v1/users is easy for clients and gateways to understand. Use versioning for breaking contract changes, not every small internal refactor.
app.enableVersioning({
type: VersioningType.URI,
});
@Controller({ path: 'users', version: '1' })
export class UsersV1Controller {}
NestJS can cache responses or service results through CacheModule, CacheInterceptor, and external stores such as Redis. Cache only data that is safe to reuse, define invalidation rules, and avoid caching personalized responses unless the cache key includes user or tenant context.
@UseInterceptors(CacheInterceptor)
@Get('products')
findProducts() {
return this.productsService.findAll();
}
Improve performance by avoiding request-scoped providers unless needed, optimizing database queries, using Fastify for high-throughput cases, enabling caching carefully, avoiding heavy synchronous work, monitoring p95 and p99 latency, and keeping validation/serialization overhead reasonable for hot endpoints.
NestJS can run on Express or Fastify through platform adapters. Express has broad middleware compatibility and familiarity. Fastify can offer better performance and schema-driven features. The choice affects middleware, plugins, request objects, and some third-party integrations.
Good module boundaries keep feature ownership clear and reduce accidental coupling. A feature module should expose only what other modules need through exports. Avoid importing everything into every module. In larger systems, shared modules should contain stable utilities, not random business logic.
Common anti-patterns include putting business logic in controllers, making every provider request-scoped, creating circular dependencies, using global modules for everything, skipping DTO validation, leaking database entities directly as API responses, hiding too much logic in decorators, and writing tests that depend on real external services without isolation.
Circular dependencies happen when two providers or modules depend on each other. forwardRef() can break the immediate dependency cycle, but it should not be the first design choice. Often the better fix is extracting shared logic into a third service or redefining module boundaries.
@Module({
imports: [forwardRef(() => OrdersModule)],
})
export class PaymentsModule {}
A good CRUD controller uses route decorators, DTO validation, a service layer, and correct status codes. The controller should not contain database details. The example below shows the controller shape; UsersService owns the business and persistence logic.
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
}
Explore 500+ free tutorials across 20+ languages and frameworks.