Building APIs with NestJS: Modules, DI, DTO Validation, Guards, Interceptors and TypeORM

Key takeaways

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

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.

The main practical payoff isn’t raw performance — it’s predictability. Every NestJS app follows the same modules-controllers-services shape, so a developer who’s worked in one NestJS codebase can navigate an unfamiliar one by pattern-matching folder structure, rather than reading through the whole codebase to figure out where things live. A flat Express app has no such convention enforced by the framework, so that navigability depends entirely on whatever structure the original author happened to choose.


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

Modules

Modules are the organizational unit of a NestJS app. Every feature gets its own module. The exports: [UsersService] line is easy to skip past but is what actually makes dependency injection work across module boundaries — a provider declared in providers is only visible to things declared within the same module by default. Forgetting to export a service that another module’s controller or service needs to inject produces a genuinely confusing runtime error (“Nest can’t resolve dependencies”) that gives no direct hint the fix is a one-line exports addition in a completely different file.

// 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 {}

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);
  }
}

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);
  }
}

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.


Guards (Authentication & Authorization)

Guards determine whether a request should proceed. JwtAuthGuard below has a constructor-injected JwtService, which matters for how you apply it globally: new JwtAuthGuard(jwtService) would only work if you already had a resolved JwtService instance sitting in main.ts’s scope, which you don’t by default — bootstrap() doesn’t have direct access to the DI container’s providers without extra plumbing. The pattern NestJS apps actually use for a global guard that needs injected dependencies is registering it as an APP_GUARD provider in a module, so Nest’s own DI container constructs it correctly:

// app.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
})
export class AppModule {}

The guard’s constructor(private jwtService: JwtService) then gets its JwtService resolved by Nest automatically, the same as any other injected provider — no manual instantiation involved.

// 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();
    }
  }
}

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

Interceptors

Interceptors wrap request/response handling — useful for logging, response transformation, and caching. The RxJS pipe() chain below is the part that trips people up coming from Express middleware, where you’re used to calling next() and being done — here, next.handle() returns an Observable representing the eventual response, and everything in .pipe(...) runs around that response rather than intercepting it imperatively. tap() is worth remembering specifically because it’s the one operator in this chain that doesn’t transform the value — it just observes it and runs a side effect (the timing log here), which is exactly the shape you want for logging without risking accidentally mutating the response on the way through.

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`)),
    );
  }
}

TypeORM Integration

npm install @nestjs/typeorm typeorm pg
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users/users.entity';

@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]),
  ],
})
export class AppModule {}

synchronize: process.env.NODE_ENV !== 'production' is doing real safety work here, not just following convention. TypeORM’s synchronize: true auto-generates and runs schema migrations on every app startup based on your entity definitions — genuinely convenient in local development, where a schema mismatch just gets fixed automatically. In production, that same auto-sync can silently drop columns or tables that no longer match your current entity code, with no review step and no rollback — a real, well-documented way TypeORM users have lost production data. Disabling it outside development and using TypeORM’s actual migration files (generated and reviewed as part of a deploy) for schema changes is the standard practice this line is enforcing.

// 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;
}

Exception Filters

@Catch(HttpException) scopes this filter to only HttpException and its subclasses (NotFoundException, UnauthorizedException, and the rest of Nest’s built-in exception types), which is a deliberate boundary worth keeping — a genuinely unexpected error (a null pointer bug, a database connection failure) is not an HttpException, and a global catch-all filter that formats every error the same way as a client-facing 4xx response risks hiding real bugs behind a generic error message instead of surfacing them in logs. A separate, broader filter (@Catch() with no argument) for truly unhandled exceptions, logging the full error server-side while still returning a generic 500 to the client, is the usual complement to this one.

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(),
    });
  }
}

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);
  });
});

Frequently Asked Questions (FAQ)

Q. After registering JwtAuthGuard as APP_GUARD, my login route returns 401 too. How do I exclude it?

A. A global guard runs on every route, including login, signup and health checks, so those routes need an opt-out. The usual pattern is a custom @Public() decorator built with SetMetadata('isPublic', true). In the guard, inject Reflector and use reflector.getAllAndOverride('isPublic', [context.getHandler(), context.getClass()]) to return true early for marked routes. Because the guard is created by the DI container through APP_GUARD, injecting Reflector works without extra wiring.