NestJS: Building Scalable Node.js Apps
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.
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:
- Modules: group related features into one unit
- Controllers: handle incoming HTTP requests and return responses
- Services (Providers): hold business logic, injected via DI
- Pipes: transform and validate request data before it reaches your handler
- Guards: decide whether a request is allowed to continue
- Interceptors: wrap requests/responses for logging, caching or reshaping output
- Middleware: plain Express/Fastify middleware, same as you're used to
| 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.
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. 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.
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.
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 {}
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.
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.
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.
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.
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 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.
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. 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.
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 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.
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
| 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 |
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
synchronizeis off and schema changes go through TypeORM migrations.ValidationPipeuseswhitelist: true, so unknown fields likerolecan't sneak in.- Global guards have exceptions. If you register
JwtAuthGuardwithAPP_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.
@BeforeInsertonly fires on insert, so a password changed throughupdate()is saved in plain text unless you hash it there too (or add@BeforeUpdate). - Passwords never leave the API. Returning the
Userentity as is sends the hash to the client. Use@Exclude()withClassSerializerInterceptor, or map to a response DTO. JWT_SECRETcomes fromConfigServiceand 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.