Skip to main content

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:

typescript
// 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/microservices and @nestjs/websockets)
  • High traffic, 10k+ RPS: NestJS with the Fastify adapter (roughly 2x throughput over Express default)

Comparison table

AspectExpressNestJSFastifyKoa
TypeScriptOptionalBuilt-inOptionalOptional
StructureNoneModules enforce layoutNoneMinimal
DI containerManualBuilt-inManualManual
DecoratorsNoYesNoNo
CLINoYes (nest CLI)NoNo
Learning curve~1 hour~1 day~1 hour~2 hours
Best forScripts, small appsTeam APIs, large backendsHigh-perf APIsLightweight 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.

typescript
// 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:

typescript
// 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:

typescript
// Wrong constructor() { this.service = new UsersService(); // bypasses the DI container } // Right constructor(private service: UsersService) {} // auto-injected as singleton

Manual 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:

typescript
// 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 message

NestJS 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/jwt is the standard combination across most NestJS projects
  • WebSocket apps: @nestjs/websockets with 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

typescript
// 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

typescript
// 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

typescript
// 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 startup

Circular 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?