Backend

NestJS: Building Scalable Node.js Apps

Mayur Dabhi
Mayur Dabhi
June 3, 2026
14 min read

An Express app with five routes is a pleasure. The same app a year later, with forty routes, three people committing to it, and auth checks copy-pasted into half the handlers, usually isn't. Express doesn't tell you where anything goes, so every team ends up inventing its own folder layout and its own way of passing a database connection around, and then arguing about it.

NestJS answers those questions for you. It runs on top of Express (or Fastify), it's written for TypeScript, and it gives you modules, dependency injection, guards and pipes out of the box. In this post I'll set up a project, build a users API with controllers, services and DTO validation, hook it up to PostgreSQL through TypeORM, add JWT and role guards, and write unit tests against a mocked repository.

Why NestJS?

Beyond plain REST, Nest ships official packages for microservices, GraphQL, WebSockets and gRPC. They all use the same module and DI system, so what you learn for an HTTP API carries over when you add a queue consumer or a WebSocket gateway later.

What is NestJS?

If you've written Angular, Nest will feel familiar. Kamil Myśliwiec released it in 2017 and borrowed Angular's ideas directly: TypeScript decorators, a dependency injection container, and a module system that decides what can see what.

Where Express hands you a blank file, Nest has a named place for each 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

The CLI generates the boilerplate, so setup takes a couple of minutes.

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. The start:dev script runs in watch mode, so saving a file restarts the server.

Core Architecture: Modules, Controllers, Services

Almost everything you write in Nest is one of three things: a module, a controller or a service. Once you know how a request moves between them, the rest of the framework makes a lot more sense.

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 Nest app has at least one module: the root AppModule. A module lists its controllers and providers, and decides which providers other modules are allowed to use. Feature modules like UsersModule keep one domain in one place, which also makes them easier to test on their own.

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

nest generate resource users creates the module, controller, service and DTOs in one go. Pick "REST API" when it asks and you also get CRUD method stubs with typed DTOs.

Building Controllers

A controller class gets @Controller(), and each handler method gets an HTTP verb decorator like @Get(). Route params, query strings and the body come in through parameter decorators, so you don't dig through req.params by hand.

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 converts the URL string to a number and responds with 400 Bad Request if the value isn't numeric. No parseInt() and no hand-written check.

Services and Dependency Injection

Services contain business logic and are decorated with @Injectable(), registering them in NestJS's DI container. When a controller asks for a service in its constructor, Nest creates the instance and passes it in. You never call new UsersService() yourself.

The payoff shows up in tests. To swap a real implementation for a mock, you change what the container provides, and the controller code stays as it is.

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. Nest's exception layer catches it and sends a 404 JSON response, so the controller doesn't need a try/catch.

The error you'll hit first

Sooner or later you'll see Nest can't resolve dependencies of the UsersService (?). Please make sure that the argument "UserRepository" at index [0] is available in the UsersModule context. It almost always means a provider isn't registered in the module that needs it: here, a missing TypeOrmModule.forFeature([User]) in UsersModule. The same thing happens when you inject a service from another module that doesn't list it in exports. The ? in the message points to the argument position Nest couldn't fill.

Data Validation with Pipes and DTOs

A DTO (Data Transfer Object) describes what a request body should look like. Put class-validator decorators on it, turn on ValidationPipe, and bad requests get rejected before your handler runs.

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 the client pass straight through to your handlers. That's the classic mass assignment hole: someone adds role: "admin" to a signup request, and Object.assign or repository.create() happily saves it.

Database Integration with TypeORM

Nest works with TypeORM, Prisma, Mongoose and others. I'm using TypeORM here because it has an official @nestjs/typeorm package and its entities use decorators too, so the code style stays consistent.

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. With synchronize on, TypeORM creates and alters tables from these classes. That's handy locally, but it can drop columns when you rename a property, which is why the config above turns it off in production. Use migrations there.

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 run after middleware and before the route handler, and you can stack several on one route, which is how auth and role checks end up as separate small classes.

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 unit test a service by handing it a mock repository through the DI container, so there's no test database to spin up.

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 the repository comes in through DI, you replace it with a mock object and the service code runs unchanged against your test doubles. Keep in mind that this tests your logic, not your queries. A wrong where clause will pass here, so I'd still add a few e2e tests against a real database.

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

When I'd use NestJS (and when I wouldn't)

I'd reach for Nest when the API is going to live for years and more than one or two people will work on it. The conventions are the main value: a new developer opens src/orders/ and already knows what the module, controller, service and DTO files do. DI also makes services easy to test without monkey-patching imports.

I wouldn't use it for a small webhook receiver, a single Lambda function or a weekend prototype. You pay for the structure up front with more files, more decorators and slower cold starts than a bare Express or Fastify handler, and on a tiny service that cost never pays back. It's also a lot of new ideas at once for a team with no TypeScript or Angular background.

What to check before going to production

  • synchronize is off and schema changes go through TypeORM migrations.
  • ValidationPipe uses whitelist: true, so unknown fields like role can't sneak in.
  • Global guards have exceptions. If you register JwtAuthGuard with APP_GUARD, it also blocks your login and health check routes. Add a @Public() metadata decorator and have the guard skip routes that carry it.
  • Password hashing covers updates. @BeforeInsert only fires on insert, so a password changed through update() is saved in plain text unless you hash it there too (or add @BeforeUpdate).
  • Passwords never leave the API. Returning the User entity as is sends the hash to the client. Use @Exclude() with ClassSerializerInterceptor, or map to a response DTO.
  • JWT_SECRET comes from ConfigService and the app fails at startup if it's missing, not on the first request.

After that, the packages I'd look at next are @nestjs/swagger (API docs generated from your DTOs and decorators), @nestjs/passport if you want ready-made auth strategies, and @nestjs/cache-manager once some endpoints need caching. The microservice transports and @nestjs/graphql are there when you need them, but I wouldn't add either just because they exist.

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.