What is NestJS and what problems does it solve?
NestJS is a TypeScript-based Node.js framework that puts Angular-style structure (modules, decorators, dependency injection) on top of Express to build scalable server-side applications.
Theory
TL;DR
- NestJS is like taking scattered Express files and putting each feature into a labeled bag: one module per domain, all wired together automatically
- Main difference: Express has zero rules; NestJS enforces a folder contract through decorators and modules
- Under the hood it still runs Express (or Fastify), so NestJS is a layer on top, not a replacement
- Decision rule: solo prototype under a week? Use Express. Team project or API with 5+ endpoints? NestJS
Quick example
The same GET /users endpoint in both approaches:
// Express: works fine, but where does this go as the app grows?
const express = require('express');
const app = express();
app.get('/users', (req, res) => res.json([{ id: 1, name: 'Alice' }]));
app.listen(3000);
// NestJS: auto-discovered via decorators, one clear location per feature
import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
getUsers() {
return [{ id: 1, name: 'Alice' }]; // GET /users → [{"id":1,"name":"Alice"}]
}
}
// Start with: nest start (scans modules automatically)For three routes, Express is fine. For thirty routes across five developers with no agreed structure, it falls apart fast.
Key difference
Express hands you a blank canvas. Routes go anywhere, imports are manual, no rules exist. NestJS draws the lines first: every feature lives in a module (like Angular's NgModule), controllers handle HTTP via decorators (@Get, @Post), and services hold logic with dependencies injected through the constructor. This removes the classic team argument of "where does this route actually live?" that shows up in every code review on unstructured Express projects.
When to use
- Solo prototype, under a week: Express (no learning curve, faster start)
- Team project or API with 5+ endpoints: NestJS (structure stops merge conflicts before they start)
- Microservices or WebSockets: NestJS (built-in via
@nestjs/microservicesand@nestjs/websockets) - High traffic, 10k+ RPS: NestJS with the Fastify adapter (roughly 2x throughput over Express default)
Comparison table
| Aspect | Express | NestJS | Fastify | Koa |
|---|---|---|---|---|
| TypeScript | Optional | Built-in | Optional | Optional |
| Structure | None | Modules enforce layout | None | Minimal |
| DI container | Manual | Built-in | Manual | Manual |
| Decorators | No | Yes | No | No |
| CLI | No | Yes (nest CLI) | No | No |
| Learning curve | ~1 hour | ~1 day | ~1 hour | ~2 hours |
| Best for | Scripts, small apps | Team APIs, large backends | High-perf APIs | Lightweight middleware |
How NestJS bootstraps
NestJS scans your main.ts, reads TypeScript decorators (@Module, @Controller) using Node's reflect-metadata package, builds a dependency graph, and instantiates providers in the correct order. Then it registers Express route handlers from your decorator-annotated methods. All of this runs before the first request arrives.
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule); // scans decorators, builds DI graph
await app.listen(3000);
}
bootstrap();Teams I have seen move from plain Express to NestJS say the same thing after the first month: they spend less time arguing about where to put new code.
Common mistakes
Dumping all routes into app.controller.ts:
// Wrong: one controller becomes the entire app
@Controller()
export class AppController {
@Get('users') getUsers() { ... }
@Get('products') getProducts() { ... }
@Post('orders') createOrder() { ... }
// 500 lines later, nothing is testable in isolation
}Modules exist specifically to prevent this. One controller per domain. users.controller.ts handles users only.
Creating services manually instead of using DI:
// Wrong
constructor() {
this.service = new UsersService(); // bypasses the DI container
}
// Right
constructor(private service: UsersService) {} // auto-injected as singletonManual new breaks auto-mocking in tests and skips singleton scoping. This is the most common NestJS mistake on Stack Overflow.
Forgetting to register providers in the module:
// Wrong: service missing from providers array
@Module({
controllers: [UsersController],
// providers: [UsersService] <- not listed
})
export class UsersModule {}
// Result: injection fails, 404 in production with no clear error messageNestJS ignores any provider not listed in providers. Always register your services there.
Real-world usage
- Adidas: NestJS modules for orders and inventory, GraphQL via
@nestjs/graphql - Autodesk: microservices with Kafka through
@nestjs/microservices - Auth flows:
@nestjs/passport+@nestjs/jwtis the standard combination across most NestJS projects - WebSocket apps:
@nestjs/websocketswith a Socket.io gateway
Follow-up questions
Q: Walk through what happens when NestJS starts. What does NestFactory.create() do?
A: It scans AppModule, reflects over every @Module decorator using reflect-metadata, builds a dependency graph, and instantiates providers in dependency order. Then it registers Express route handlers. app.listen(3000) binds the HTTP server last.
Q: What is the difference between a provider and a controller?
A: Controllers handle HTTP: they receive requests and return responses. Providers (marked @Injectable()) hold business logic and can be injected anywhere. A controller calls a provider; a provider should not know HTTP exists.
Q: How do you switch from Express to Fastify?
A: Pass a FastifyAdapter to NestFactory.create(AppModule, new FastifyAdapter()). All decorators stay exactly the same. Fastify gives roughly 2x throughput at high load.
Q: What is the execution order of guards, pipes, and interceptors?
A: Guard runs first (auth check). Pipe runs next (validates and transforms input). Interceptor wraps the controller call, running before and after. Exception filter catches anything that throws. This order is fixed by NestJS internals.
Q: How would you design a NestJS backend for 1 million requests per day?
A: Separate modules per domain, Redis caching via @nestjs/cache-manager, health checks with @nestjs/terminus, transient scope for stateful services, and horizontal scaling via Kubernetes with microservices communicating through message queues.
Examples
Basic: module, controller, and service wired together
// users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService], // NestJS injects this into the controller automatically
})
export class UsersModule {}
// users.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
private users = [{ id: 1, name: 'Bob', email: 'bob@example.com' }];
findAll() { return this.users; }
findOne(id: number) { return this.users.find(u => u.id === id); }
}
// users.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {} // injected automatically
@Get()
getUsers() { return this.usersService.findAll(); } // GET /users → all users
@Get(':id')
getUser(@Param('id') id: string) {
return this.usersService.findOne(+id); // GET /users/1 → single user object
}
}UsersService is listed in providers, so NestJS creates one singleton instance and passes it into the controller constructor. No new, no manual imports beyond the type.
Intermediate: input validation with a DTO and ValidationPipe
// create-user.dto.ts
import { IsString, IsEmail, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
}
// users.controller.ts (POST endpoint added)
import { Controller, Post, Body, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
@Post()
@UsePipes(new ValidationPipe())
createUser(@Body() dto: CreateUserDto) {
// Bad input never reaches this line
return { message: `Created user ${dto.name}` };
}
}
// POST /users with { "name": "A", "email": "notanemail" }
// → 400: ["name must be longer than or equal to 2 characters", "email must be an email"]ValidationPipe reads the class-validator decorators on your DTO and rejects bad input before it reaches controller logic. No manual if-checks needed.
Advanced: resolving circular dependencies with forwardRef
// cats.service.ts
import { Injectable, forwardRef, Inject } from '@nestjs/common';
import { DogsService } from '../dogs/dogs.service';
@Injectable()
export class CatsService {
constructor(
@Inject(forwardRef(() => DogsService))
private dogsService: DogsService,
) {}
meow() { return 'meow'; }
}
// dogs.service.ts
import { Injectable, forwardRef, Inject } from '@nestjs/common';
import { CatsService } from '../cats/cats.service';
@Injectable()
export class DogsService {
constructor(
@Inject(forwardRef(() => CatsService))
private catsService: CatsService,
) {}
bark() { return this.catsService.meow() + ' woof'; }
}
// Without forwardRef on both sides:
// → Error: "Cannot resolve dependency CatsService" at startupCircular dependencies usually signal a design problem worth fixing. But when you genuinely need two services that depend on each other, forwardRef lets NestJS resolve them at runtime instead of crashing at startup. Mark it as technical debt and schedule the refactor.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.