NestJS: Building Scalable Node.js Apps
If you've ever built a Node.js application with Express, you know the freedom it offers — and the chaos that follows as your codebase grows. Express is minimal by design, leaving architectural decisions entirely up to you. NestJS takes the opposite approach: it brings structure, conventions, and Angular-inspired patterns to the Node.js ecosystem, giving teams a framework they can scale without losing their minds.
Built on top of Express (or optionally Fastify), NestJS is a TypeScript-first framework that enforces a modular, opinionated architecture. With features like dependency injection, decorators, guards, interceptors, and pipes, NestJS helps you build enterprise-grade APIs that are maintainable, testable, and easy to understand — even when the team grows or requirements change.
NestJS is downloaded over 7 million times per week on npm. Companies like Adidas, Capgemini, Autodesk, and Roche use it in production. Its built-in support for microservices, GraphQL, WebSockets, and gRPC makes it a complete backend framework for modern applications.
What is NestJS?
NestJS is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Released in 2017 by Kamil Myśliwiec, it takes inspiration from Angular's architecture — using TypeScript decorators, dependency injection, and a module system to organize code.
Unlike Express which gives you a blank canvas, NestJS provides clear conventions for every concern:
- Modules — organize related features into cohesive units
- Controllers — handle incoming HTTP requests and return responses
- Services (Providers) — contain business logic, injected via DI
- Pipes — transform and validate request data before it hits your handler
- Guards — determine whether a request should be allowed to proceed
- Interceptors — intercept requests/responses for logging, caching, transformation
- Middleware — standard Express/Fastify middleware support
| Feature | Express | NestJS |
|---|---|---|
| Language | JavaScript (optional TS) | TypeScript (first-class) |
| Architecture | None — DIY | Modular, opinionated |
| Dependency Injection | Manual wiring | Built-in DI container |
| Testing | Manual setup required | Built-in testing utilities |
| Microservices | External libraries needed | Built-in transport support |
| Learning Curve | Low | Medium (fast for Angular devs) |
| Performance overhead | Minimal | Minimal (thin layer over Express) |
| Best for | Simple APIs, prototypes | Enterprise apps, large teams |
Installation and Setup
Getting started with NestJS is straightforward. The CLI handles all the boilerplate for you.
Install the NestJS CLI
Install globally to access the nest command for generating projects and scaffolding code.
npm install -g @nestjs/cli
nest --version
Create a New Project
The CLI scaffolds a complete project with TypeScript configuration, testing setup, and a sample module.
nest new my-nest-app
cd my-nest-app
npm run start:dev
Explore the Project Structure
NestJS generates a well-organized directory with src/ for application code, test/ for e2e tests, and all configuration files ready to go.
my-nest-app/
├── src/
│ ├── app.controller.ts # Root controller
│ ├── app.module.ts # Root module (entry point)
│ ├── app.service.ts # Root service
│ └── main.ts # Application bootstrap
├── test/
│ └── app.e2e-spec.ts
├── tsconfig.json
├── nest-cli.json
└── package.json
Visit Your Running App
With npm run start:dev running, open http://localhost:3000. Hot-reload is enabled by default — save a file and the server restarts automatically.
Core Architecture: Modules, Controllers, Services
NestJS's entire architecture revolves around three building blocks. Understanding how they relate is the key to productive NestJS development.
NestJS request lifecycle: Middleware → Guards → Pipes → Controller → Service → Database
Modules
Every NestJS application has at least one module — the root AppModule. Modules group related functionality and define which controllers and providers they expose. Feature modules (like UsersModule) keep each domain concern isolated and independently testable.
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // Make available to other modules
})
export class UsersModule {}
// Register in AppModule:
@Module({
imports: [UsersModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Generate an entire resource (module + controller + service + DTOs) in one command: nest generate resource users. Choose "REST API" and it even scaffolds CRUD boilerplate with proper TypeScript types.
Building Controllers
Controllers handle incoming HTTP requests. Each controller is decorated with @Controller(), and each method is decorated with an HTTP verb decorator. NestJS automatically extracts route parameters, query strings, and request bodies via parameter decorators — no manual req.params parsing needed.
import {
Controller, Get, Post, Put, Delete,
Param, Body, 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') // Base path: /users
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get() // GET /users or GET /users?role=admin
findAll(@Query('role') role?: string) {
return this.usersService.findAll(role);
}
@Get(':id') // GET /users/42
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@Post() // POST /users
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Put(':id') // PUT /users/42
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(id, updateUserDto);
}
@Delete(':id') // DELETE /users/42
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
}
Notice ParseIntPipe on the id parameter. It automatically converts the URL string to a number and throws a 400 Bad Request if the value isn't numeric — no manual parseInt() or validation required.
Services and Dependency Injection
Services contain business logic and are decorated with @Injectable(), registering them in NestJS's DI container. When a controller declares a service in its constructor, NestJS creates and injects the instance automatically — you never call new UsersService() yourself.
This design is powerful for testing: swap real implementations with mocks simply by changing what the DI container provides, without touching any controller code.
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';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<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.findOneBy({ id });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id); // Throws 404 if missing
Object.assign(user, updateUserDto);
return this.usersRepository.save(user);
}
async remove(id: number): Promise<void> {
const user = await this.findOne(id);
await this.usersRepository.remove(user);
}
}
The NotFoundException thrown in findOne is a NestJS built-in. The framework catches it and returns a proper 404 JSON response automatically — no try/catch needed in the controller.
Data Validation with Pipes and DTOs
DTOs (Data Transfer Objects) define the shape of incoming data. Combined with class-validator decorators and NestJS's ValidationPipe, you get automatic request validation with no boilerplate in your handlers.
npm install class-validator class-transformer
import {
IsEmail, IsString, IsNotEmpty, MinLength,
MaxLength, IsEnum, IsOptional,
} from 'class-validator';
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
MODERATOR = 'moderator',
}
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8)
@MaxLength(50)
password: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole = UserRole.USER;
}
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Reject requests with unknown props
transform: true, // Auto-cast to DTO types
}),
);
app.setGlobalPrefix('api'); // All routes: /api/users, /api/posts
await app.listen(3000);
}
bootstrap();
Always enable whitelist: true in your ValidationPipe. Without it, extra properties sent by clients flow through your handlers untouched — a common vector for mass assignment vulnerabilities where attackers escalate privileges by sending fields like role: "admin".
Database Integration with TypeORM
NestJS has first-class support for TypeORM, Prisma, Mongoose, and other ORMs. TypeORM is the most common choice — its decorator-based entity definitions fit naturally into the NestJS paradigm.
Install TypeORM and a database driver
Install the NestJS TypeORM integration alongside your database driver (PostgreSQL shown here).
npm install @nestjs/typeorm typeorm pg
# For MySQL: npm install @nestjs/typeorm typeorm mysql2
Configure the database connection
Import TypeOrmModule in AppModule using the async factory pattern so it can read environment variables via ConfigService.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UsersModule } from './users/users.module';
import { User } from './users/entities/user.entity';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 5432),
username: config.get('DB_USER', 'postgres'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME', 'nestapp'),
entities: [User],
synchronize: config.get('NODE_ENV') !== 'production',
}),
}),
UsersModule,
],
})
export class AppModule {}
Define a TypeORM entity
Entities map TypeScript classes to database tables. TypeORM can auto-generate the schema from these class definitions.
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn, BeforeInsert,
} from 'typeorm';
import * as bcrypt from 'bcrypt';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column({ default: 'user' })
role: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@BeforeInsert()
async hashPassword() {
this.password = await bcrypt.hash(this.password, 10);
}
}
Register the entity in the feature module
Use TypeOrmModule.forFeature() to make the repository injectable within the module's scope.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Dependency injection chains the Repository into your Service with zero manual wiring
Guards and Authentication
Guards implement the CanActivate interface and return a boolean indicating whether the current request should proceed. They sit between middleware and the route handler, making authentication and authorization elegantly composable.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException();
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: process.env.JWT_SECRET,
});
request['user'] = payload; // Attach decoded payload to request
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractToken(request: Request): string | undefined {
const [type, token] =
request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SetMetadata } from '@nestjs/common';
// Decorator to set required roles on a route
export const Roles = (...roles: string[]) =>
SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
'roles',
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) return true; // No restriction
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user?.role);
}
}
import { UseGuards } from '@nestjs/common';
// Protect a whole controller
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
@Get()
findAll() { /* accessible to any authenticated user */ }
@Delete(':id')
@UseGuards(RolesGuard)
@Roles('admin') // Only admins can delete
remove(@Param('id', ParseIntPipe) id: number) { /* ... */ }
}
// Apply globally in a module (enables DI inside the guard):
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}
Testing in NestJS
NestJS ships with Jest pre-configured and provides a testing module builder that mirrors the module system. You can create isolated unit tests for services by providing mock repositories through the DI container — no integration database required.
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
const mockRepo = {
find: jest.fn(),
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
};
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepo },
],
}).compile();
service = module.get<UsersService>(UsersService);
jest.clearAllMocks();
});
it('throws NotFoundException when user not found', async () => {
mockRepo.findOneBy.mockResolvedValue(null);
await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
});
it('returns user when found', async () => {
const user = { id: 1, name: 'Alice', email: 'alice@example.com' } as User;
mockRepo.findOneBy.mockResolvedValue(user);
expect(await service.findOne(1)).toEqual(user);
});
it('creates a user and persists it', async () => {
const dto = { name: 'Bob', email: 'bob@example.com', password: 'secret123' };
const created = { id: 2, ...dto } as User;
mockRepo.create.mockReturnValue(created);
mockRepo.save.mockResolvedValue(created);
expect(await service.create(dto as any)).toEqual(created);
expect(mockRepo.save).toHaveBeenCalledWith(created);
});
});
The Test.createTestingModule() API lets you build isolated module instances. Because of DI, you simply replace the repository with a mock object — and the service's internal logic runs exactly as it would in production, just against your controlled test doubles.
NestJS Decorator Quick Reference
| Decorator | Scope | Purpose |
|---|---|---|
@Module() | Class | Declares a NestJS module |
@Controller() | Class | Marks a class as a route controller |
@Injectable() | Class | Registers class in DI container |
@Get() @Post() @Put() @Delete() | Method | Maps HTTP methods to handlers |
@Param() @Query() @Body() | Parameter | Extracts data from requests |
@UseGuards() | Class/Method | Attaches guards to routes |
@UseInterceptors() | Class/Method | Attaches interceptors |
@UsePipes() | Class/Method | Attaches pipes for validation/transform |
@HttpCode() | Method | Overrides default HTTP response status |
@InjectRepository() | Parameter | Injects a TypeORM repository |
Key Takeaways and Next Steps
NestJS provides the structure that Express deliberately omits — and that structure pays dividends as your application and team scale. Here's a summary of what makes it worth the learning curve:
Why NestJS Wins at Scale
- Opinionated architecture — Every team member knows exactly where code lives. No more "where should I put this?" debates
- Built-in DI container — Dependencies are testable and swappable without touching consuming code
- TypeScript first — Catch errors at compile time, not at 3 AM in production
- Composable cross-cutting concerns — Guards, pipes, interceptors, and middleware each have a single clear job
- Microservices ready — Switch from HTTP to TCP, NATS, RabbitMQ, or Kafka by changing the transport layer declaration
- Excellent documentation — The official NestJS docs are some of the clearest in the Node.js ecosystem
From here, natural next steps include:
- Add Swagger —
npm install @nestjs/swaggerauto-generates API docs from your DTOs and route decorators - Implement JWT auth end-to-end — The
@nestjs/passportpackage integrates cleanly with Passport.js strategies - Explore microservices — NestJS's transport layer makes distributed architectures approachable from a single familiar API
- Try GraphQL —
@nestjs/graphqlsupports code-first and schema-first approaches with full TypeScript inference - Add caching —
@nestjs/cache-managerwith Redis provides production-grade response caching in a few lines
"NestJS isn't just a framework — it's an architecture in a box. The guardrails it imposes aren't restrictions; they're the conventions your team would have invented anyway, done right from the start."
Whether you're migrating an Express codebase or starting fresh, NestJS gives your Node.js backend the structure, type safety, and scalability that serious projects demand. The learning curve is real, but so is the payoff — especially for teams that value maintainability over minimalism.