Backend

NestJS: Building Scalable Node.js Apps

Mayur Dabhi
Mayur Dabhi
June 3, 2026
14 min read

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.

Why NestJS?

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:

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.

1

Install the NestJS CLI

Install globally to access the nest command for generating projects and scaffolding code.

Terminal
npm install -g @nestjs/cli
nest --version
2

Create a New Project

The CLI scaffolds a complete project with TypeScript configuration, testing setup, and a sample module.

Terminal
nest new my-nest-app
cd my-nest-app
npm run start:dev
3

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.

Project Structure
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
4

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.

HTTP Request Middleware Guards Pipes Controller @Controller() @Get() @Post() Route Handlers Service @Injectable() Business Logic Data Access DB TypeORM UsersModule HTTP Response (+ Interceptors)

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.

src/users/users.module.ts
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 {}
CLI Shortcut

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.

src/users/users.controller.ts
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.

src/users/users.service.ts
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.

Terminal — install validation packages
npm install class-validator class-transformer
src/users/dto/create-user.dto.ts
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;
}
src/main.ts — enable global validation
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();
Security Note

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.

1

Install TypeORM and a database driver

Install the NestJS TypeORM integration alongside your database driver (PostgreSQL shown here).

Terminal
npm install @nestjs/typeorm typeorm pg
# For MySQL: npm install @nestjs/typeorm typeorm mysql2
2

Configure the database connection

Import TypeOrmModule in AppModule using the async factory pattern so it can read environment variables via ConfigService.

src/app.module.ts
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 {}
3

Define a TypeORM entity

Entities map TypeScript classes to database tables. TypeORM can auto-generate the schema from these class definitions.

src/users/entities/user.entity.ts
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);
  }
}
4

Register the entity in the feature module

Use TypeOrmModule.forFeature() to make the repository injectable within the module's scope.

src/users/users.module.ts (final)
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 {}
UsersService @Injectable() Repository<User> find, save, remove... TypeORM Generates SQL DB DI injects builds SQL executes NestJS DI wires Repository into Service automatically

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.

src/users/users.service.spec.ts
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

DecoratorScopePurpose
@Module()ClassDeclares a NestJS module
@Controller()ClassMarks a class as a route controller
@Injectable()ClassRegisters class in DI container
@Get() @Post() @Put() @Delete()MethodMaps HTTP methods to handlers
@Param() @Query() @Body()ParameterExtracts data from requests
@UseGuards()Class/MethodAttaches guards to routes
@UseInterceptors()Class/MethodAttaches interceptors
@UsePipes()Class/MethodAttaches pipes for validation/transform
@HttpCode()MethodOverrides default HTTP response status
@InjectRepository()ParameterInjects 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:

  1. Add Swaggernpm install @nestjs/swagger auto-generates API docs from your DTOs and route decorators
  2. Implement JWT auth end-to-end — The @nestjs/passport package integrates cleanly with Passport.js strategies
  3. Explore microservices — NestJS's transport layer makes distributed architectures approachable from a single familiar API
  4. Try GraphQL@nestjs/graphql supports code-first and schema-first approaches with full TypeScript inference
  5. Add caching@nestjs/cache-manager with 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.

NestJS Node.js TypeScript Backend API TypeORM Framework
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.