NestJS Complete Guide | Build Scalable Node.js APIs

NestJS Complete Guide | Build Scalable Node.js APIs

이 글의 핵심

NestJS brings Angular-style architecture to Node.js backend development — modules, dependency injection, decorators, and a strong opinions on project structure. This guide covers everything you need to build production-ready APIs.

What This Guide Covers

NestJS is a structured Node.js framework that scales — it enforces modules, dependency injection, and decorators, making large codebases maintainable. This guide covers the core building blocks with a real REST API example.

Real-world insight: Migrating from a flat Express codebase to NestJS made onboarding new developers 3× faster — the project structure is immediately predictable to anyone who’s seen one NestJS app.


Setup

npm install -g @nestjs/cli
nest new my-api
cd my-api
npm run start:dev

Default app runs on http://localhost:3000.


Project Structure

src/
  app.module.ts          ← root module
  main.ts                ← bootstrap
  users/
    users.module.ts
    users.controller.ts
    users.service.ts
    users.entity.ts
    dto/
      create-user.dto.ts
      update-user.dto.ts

1. Modules

Modules are the organizational unit of a NestJS app. Every feature gets its own module.

// 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],  // expose to other modules
})
export class UsersModule {}
// app.module.ts
@Module({
  imports: [UsersModule, PostsModule],
})
export class AppModule {}

2. Controllers

Controllers handle incoming HTTP requests and return responses.

// users/users.controller.ts
import { Controller, Get, Post, Body, Param, Put, Delete, HttpCode } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id);
  }

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Put(':id')
  update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    return this.usersService.update(+id, dto);
  }

  @Delete(':id')
  @HttpCode(204)
  remove(@Param('id') id: string) {
    return this.usersService.remove(+id);
  }
}

3. Services

Services contain business logic and are injected via dependency injection.

// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './users.entity';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private readonly repo: Repository<User>
  ) {}

  findAll() {
    return this.repo.find();
  }

  async findOne(id: number) {
    const user = await this.repo.findOneBy({ id });
    if (!user) throw new NotFoundException(`User #${id} not found`);
    return user;
  }

  async create(dto: CreateUserDto) {
    const user = this.repo.create(dto);
    return this.repo.save(user);
  }

  async update(id: number, dto: UpdateUserDto) {
    await this.findOne(id);  // throws NotFoundException if not found
    await this.repo.update(id, dto);
    return this.findOne(id);
  }

  async remove(id: number) {
    await this.findOne(id);
    await this.repo.delete(id);
  }
}

4. DTOs and Validation

DTOs define the shape of request data. Add automatic validation with class-validator:

npm install class-validator class-transformer
// dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsString()
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsOptional()
  @IsString()
  role?: string;
}

Enable global validation pipe in main.ts:

import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(3000);
}

whitelist: true strips any properties not in the DTO. transform: true auto-converts query string numbers from string to number.


5. Guards (Authentication & Authorization)

Guards determine whether a request should proceed.

// auth/jwt-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.split(' ')[1];

    if (!token) throw new UnauthorizedException();

    try {
      request.user = this.jwtService.verify(token);
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }
}

// Apply globally
app.useGlobalGuards(new JwtAuthGuard(jwtService));

// Or per controller / route
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
  return req.user;
}

6. Interceptors

Interceptors wrap request/response handling — useful for logging, response transformation, and caching.

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const start = Date.now();
    return next.handle().pipe(
      map(data => ({ data, success: true })),  // wrap all responses
      tap(() => console.log(`Request took ${Date.now() - start}ms`)),
    );
  }
}

7. TypeORM Integration

npm install @nestjs/typeorm typeorm pg
// app.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DB_HOST,
      port: 5432,
      username: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: process.env.DB_NAME,
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: process.env.NODE_ENV !== 'production',
    }),
    TypeOrmModule.forFeature([User]),
  ],
})
// users/users.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  @Column({ select: false })
  password: string;

  @CreateDateColumn()
  createdAt: Date;
}

8. Exception Filters

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception.getStatus();

    response.status(status).json({
      statusCode: status,
      message: exception.message,
      timestamp: new Date().toISOString(),
    });
  }
}

9. Testing

// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './users.entity';

const mockRepo = {
  find: jest.fn(),
  findOneBy: jest.fn(),
  create: jest.fn(),
  save: 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);
  });

  it('should return all users', async () => {
    mockRepo.find.mockResolvedValue([{ id: 1, name: 'Alice' }]);
    const users = await service.findAll();
    expect(users).toHaveLength(1);
  });
});

Key Takeaways

ConceptPurpose
ModuleOrganizes related controllers, services, and providers
ControllerMaps HTTP routes to service methods
ServiceContains business logic, injected via DI
DTO + PipeValidates and transforms incoming data
GuardBlocks unauthorized requests
InterceptorWraps request/response for logging, transforms
FilterHandles exceptions and formats error responses

NestJS’s structure pays dividends in large teams and long-lived projects. The DI system makes testing straightforward — swap any dependency with a mock in TestingModule. Start with modules and services, add guards when auth is needed, and use interceptors for cross-cutting concerns like logging.