18 KiB
Backend Frameworks — NestJS Patterns
NestJS
Overview
Production patterns for building TypeScript backend APIs with NestJS. Covers module architecture, dependency injection, request validation, authentication guards, database integration, testing, and deployment.
When to Use
- Building REST APIs or GraphQL servers with NestJS
- Configuring modules, providers, and dependency injection
- Creating guards, interceptors, pipes, or middleware
- Integrating Prisma, TypeORM, or MikroORM with NestJS
- Building microservices or WebSocket gateways
- Testing NestJS controllers and services
When NOT to Use
- Express without NestJS — use
expresspatterns directly - FastAPI / Django — use the
fastapiordjangoskill - Frontend — use
reactornextjs - Simple scripts — NestJS is overkill for one-file utilities
Quick Reference
| I need... | Go to |
|---|---|
| Module/DI patterns | § Architecture below |
| Request validation | § Pipes & Validation below |
| Auth guards (JWT/API key) | § Authentication below |
| Database integration | § Database below |
| Testing patterns | § Testing below |
| Error handling | § Exception Filters below |
| OpenAPI generation | § OpenAPI below |
Architecture
Module structure
Every NestJS app is a tree of modules. Keep modules focused on a single domain.
src/
├── app.module.ts # Root module — imports feature modules
├── main.ts # Bootstrap
├── common/ # Shared utilities
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ ├── interceptors/
│ └── pipes/
├── config/ # Configuration module
│ ├── config.module.ts
│ └── config.service.ts
├── users/ # Feature module
│ ├── users.module.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ ├── entities/
│ │ └── user.entity.ts
│ └── users.controller.spec.ts
└── orders/ # Another feature module
├── orders.module.ts
├── orders.controller.ts
└── orders.service.ts
Module pattern
// 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 {}
Controller + Service pattern
// users/users.controller.ts
import { Controller, Get, Post, Body, Param, Patch, Delete, HttpCode, HttpStatus } 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) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.usersService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
return this.usersService.remove(id);
}
}
// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable()
export class UsersService {
async findOne(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
async create(dto: CreateUserDto) {
return this.prisma.user.create({ data: dto });
}
async update(id: string, dto: UpdateUserDto) {
await this.findOne(id); // throws if missing
return this.prisma.user.update({ where: { id }, data: dto });
}
async remove(id: string) {
await this.findOne(id);
await this.prisma.user.delete({ where: { id } });
}
}
Pipes & Validation
Use class-validator + class-transformer with the global ValidationPipe.
Bootstrap validation globally
// main.ts
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 unknown properties with 400
transform: true, // Auto-transform payloads to DTO instances
transformOptions: { enableImplicitConversion: true },
}));
await app.listen(3000);
}
bootstrap();
DTO with validation
// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator';
export class CreateUserDto {
@IsEmail()
@MaxLength(254)
email: string;
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@IsOptional()
@IsEnum(['admin', 'member', 'viewer'])
role?: string = 'member';
}
// users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
PartialType makes all fields optional and preserves validators — no manual duplication.
Authentication
JWT Guard
// common/guards/jwt-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('Missing bearer token');
try {
const payload = await this.jwtService.verifyAsync(token);
request['user'] = payload;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
return true;
}
private extractToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
Apply guard globally with public route bypass
// app.module.ts
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
@Module({
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}
// common/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// In JwtAuthGuard.canActivate(), add at the top:
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// Usage — mark public routes
@Public()
@Get('health')
health() { return { status: 'ok' }; }
Role-based access
// common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// common/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user.role);
}
}
Exception Filters
Global Problem Details filter (RFC 9457)
Consistent with the openapi skill's convention — all errors as application/problem+json.
// common/filters/problem-details.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const exceptionResponse = exception instanceof HttpException
? exception.getResponse()
: {};
const detail = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message;
response.status(status).json({
type: `https://api.example.com/problems/${this.slugify(status)}`,
title: HttpStatus[status]?.replace(/_/g, ' ').toLowerCase() ?? 'Error',
status,
detail: Array.isArray(detail) ? detail.join('; ') : detail,
});
}
private slugify(status: number): string {
return (HttpStatus[status] ?? 'error').toLowerCase().replace(/_/g, '-');
}
}
Register globally in main.ts:
app.useGlobalFilters(new ProblemDetailsFilter());
Database
Prisma integration (recommended)
// prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
// prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
Then inject PrismaService in any service:
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// ...
}
TypeORM alternative
// users/entities/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
name: string;
@Column({ default: 'member' })
role: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Testing
Unit testing a service
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotFoundException } from '@nestjs/common';
describe('UsersService', () => {
let service: UsersService;
let prisma: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: PrismaService,
useValue: {
user: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
},
],
}).compile();
service = module.get(UsersService);
prisma = module.get(PrismaService);
});
it('throws NotFoundException when user does not exist', async () => {
jest.spyOn(prisma.user, 'findUnique').mockResolvedValue(null);
await expect(service.findOne('missing')).rejects.toThrow(NotFoundException);
});
});
E2E testing a controller
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('UsersController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
await app.init();
});
afterAll(() => app.close());
it('POST /users — creates user', () =>
request(app.getHttpServer())
.post('/users')
.send({ email: 'test@example.com', name: 'Test' })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.email).toBe('test@example.com');
}));
it('POST /users — rejects invalid email', () =>
request(app.getHttpServer())
.post('/users')
.send({ email: 'not-an-email', name: 'Test' })
.expect(400));
});
OpenAPI
NestJS has first-class OpenAPI generation via @nestjs/swagger.
// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
const config = new DocumentBuilder()
.setTitle('Acme API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
Annotate DTOs for richer specs:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'jane@example.com', maxLength: 254 })
@IsEmail()
email: string;
@ApiPropertyOptional({ enum: ['admin', 'member', 'viewer'], default: 'member' })
@IsOptional()
@IsEnum(['admin', 'member', 'viewer'])
role?: string = 'member';
}
Use the @nestjs/swagger CLI plugin to auto-generate @ApiProperty decorators from TypeScript types — saves boilerplate.
Configuration
Use @nestjs/config with Zod validation for type-safe env vars.
// config/env.validation.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export type Env = z.infer<typeof envSchema>;
// config/config.module.ts
import { ConfigModule } from '@nestjs/config';
import { envSchema } from './env.validation';
export const AppConfigModule = ConfigModule.forRoot({
validate: (config) => envSchema.parse(config),
isGlobal: true,
});
Interceptors
Request logging
// common/interceptors/logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const { method, url } = req;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const res = context.switchToHttp().getResponse();
this.logger.log(`${method} ${url} ${res.statusCode} ${Date.now() - start}ms`);
}),
);
}
}
Response transform (envelope)
// common/interceptors/transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, map } from 'rxjs';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
intercept(_context: ExecutionContext, next: CallHandler<T>): Observable<{ data: T }> {
return next.handle().pipe(map((data) => ({ data })));
}
}
Common Pitfalls
- Circular dependencies. Module A imports Module B which imports Module A. Use
forwardRef()or restructure to break the cycle. If you needforwardRef, the architecture likely needs rethinking. - Not using
whitelist: trueon ValidationPipe. Without it, extra properties pass through silently — a security risk and a debugging nightmare. - Overusing
@Global()modules. Only truly cross-cutting concerns (config, database, logging) should be global. Feature modules should explicitly import what they need. - Testing with real database in unit tests. Unit tests should mock the database layer. Use NestJS E2E tests (with
supertest) for integration testing against a real DB. - Forgetting to
await app.init()in E2E tests. Without it, providers aren't initialized and tests fail with cryptic DI errors. - Putting business logic in controllers. Controllers should only parse requests and return responses. All logic goes in services.
- Not using
PartialType/PickType/OmitTypefor DTOs. Duplicating validation rules across create/update DTOs leads to drift. Use mapped types. - Ignoring graceful shutdown. Call
app.enableShutdownHooks()soonModuleDestroylifecycle hooks fire on SIGTERM (critical for database connections in containers).
Related Skills
openapi— OpenAPI spec design (NestJS auto-generates from decorators)typescript— TypeScript patterns (NestJS is TypeScript-first)postgresql— Database design and query optimizationdocker— Containerizing NestJS appsplaywright— E2E testing NestJS APIs through the browserauthentication— JWT / OAuth2 patterns (framework-agnostic)github-actions— CI/CD for NestJS projects