| technology | NestJS | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| domain | backend | ||||||||||||||||||||
| level | Senior/Architect | ||||||||||||||||||||
| version | 11+ | ||||||||||||||||||||
| tags |
|
||||||||||||||||||||
| ai_role | Senior NestJS Architecture Expert | ||||||||||||||||||||
| last_updated | 2026-03-23 |
This document defines the best practices for the NestJS framework. The guide is designed to ensure scalability, security, and the quality of Enterprise applications.
- Primary Goal: Provide strict architectural rules and 30 development patterns for NestJS.
- Target Tooling: AI agents (Cursor, Windsurf, Copilot) and Senior Developers.
- Tech Stack Version: NestJS 11+
Important
Architectural Contract: Use strict TypeScript typing, DI (Dependency Injection), and a modular structure. Business logic must be isolated from HTTP layer details and databases.
sequenceDiagram
participant Client
participant Controller as Controller (Thin)
participant Pipe as ValidationPipe (Global)
participant Guard as AuthGuard
participant Service as Service (Fat)
participant Repo as Repository (Port)
participant DB as Database
Client->>Controller: HTTP Request
Controller->>Guard: Check Authorization
Guard-->>Controller: Authorized
Controller->>Pipe: Validate DTO
Pipe-->>Controller: Validated
Controller->>Service: Execute Business Logic
Service->>Repo: Fetch/Save Data
Repo->>DB: Query
DB-->>Repo: Data
Repo-->>Service: Domain Entity
Service-->>Controller: Result (mapped to DTO)
Controller-->>Client: HTTP Response
@Injectable()
export class UsersService {
constructor(@InjectRepository(User) private repo: Repository<User>) {} // Tight coupling to TypeORM
}Failing to follow best practices for deterministic architecture modules tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Injectable()
export class UsersService {
constructor(@Inject('IUserRepository') private repo: IUserRepository) {} // Port interface
}Note
Internal Routing: For more context, refer back to the Backend Index.
Apply Dependency Inversion. Business logic MUST depend on abstractions (interfaces), not on specific ORMs.
@Post()
create(@Body() dto: CreateUserDto) {
if (!dto.email) throw new BadRequestException('Email required');
}Failing to follow best practices for global validationpipe tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));Enable global validation based on class-validator and whitelist to automatically strip unknown fields.
@Post()
create(@Body() body: unknown) {} // Loss of typingFailing to follow best practices for data transfer objects (dto) tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
export class CreateUserDto {
@IsEmail()
email: string;
}
@Post()
create(@Body() dto: CreateUserDto) {}All client data MUST be strictly described using DTOs with validation decorators.
@Post()
async createUser(@Body() dto: CreateDto) {
const hash = await bcrypt.hash(dto.password, 10);
return this.db.users.create({ ...dto, hash });
}Failing to follow best practices for fat controllers vs thin controllers tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Post()
async createUser(@Body() dto: CreateDto) {
return this.userService.register(dto);
}Controllers ONLY route requests. All business logic MUST reside in the Service Layer.
try { ... } catch (e) { throw new HttpException('Error', 500); }Failing to follow best practices for global exception filter tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) { /* Unified error format */ }
}
// main.ts
app.useGlobalFilters(new AllExceptionsFilter());Use Exception Filters to standardize the format of all HTTP API errors.
TypeOrmModule.forRoot({ url: process.env.DB_URL }) // Variables ARE NOT loaded yetFailing to follow best practices for async module configuration tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({ url: config.get('DB_URL') }),
inject: [ConfigService],
})For third-party modules, always use forRootAsync / registerAsync to safely inject configurations.
const secret = process.env.JWT_SECRET; // Direct accessFailing to follow best practices for configuration management tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
constructor(private configService: ConfigService) {}
const secret = this.configService.get<string>('JWT_SECRET');Use @nestjs/config for safe, strongly-typed extraction of environment variables.
@Get()
getProfile(@Req() req: Request) { return req.user; }Failing to follow best practices for custom decorators tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
export const CurrentUser = createParamDecorator((data, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user);
@Get()
getProfile(@CurrentUser() user: UserEntity) { return user; }Important
Create custom decorators for deterministic data extraction from the Request (e.g., the current user).
@Get()
getData(@Req() req) { if (!req.headers.auth) throw new UnauthorizedException(); }Failing to follow best practices for jwt guards tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@UseGuards(JwtAuthGuard)
@Get()
getData() {}Authorization MUST occur before the controller via Guards (e.g., Passport JWT strategy).
@Get()
getAdminData(@CurrentUser() user) { if (user.role !== 'ADMIN') throw new ForbiddenException(); }Failing to follow best practices for role-based access control (rbac) tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Roles('ADMIN')
@UseGuards(JwtAuthGuard, RolesGuard)
@Get()
getAdminData() {}Use custom role decorators (@Roles) and RolesGuard for declarative access control.
@Get(':id')
getUser(@Param('id') id: string) { const userId = parseInt(id, 10); }Failing to follow best practices for built-in pipes for transformation tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Get(':id')
getUser(@Param('id', ParseIntPipe) id: number) {}Use built-in Pipes (ParseIntPipe, ParseUUIDPipe) for automatic parameter conversion and validation.
return { success: true, data: result, timestamp: new Date() }; // Duplication everywhereFailing to follow best practices for response interceptors tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context, next) { return next.handle().pipe(map(data => ({ success: true, data }))); }
}Standardize the successful response structure globally using an Interceptor.
@Get()
getData() { console.log('Request started'); /* ... */ console.log('Done'); }Failing to follow best practices for logging interceptors tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(ctx, next) {
const now = Date.now();
return next.handle().pipe(tap(() => console.log(`Time: ${Date.now() - now}ms`)));
}
}Log execution time and request details abstractly using Interceptors.
await this.repo1.save(data1); await this.repo2.save(data2); // No transactionFailing to follow best practices for transaction handling (typeorm) tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
await this.dataSource.transaction(async manager => {
await manager.save(Entity1, data1);
await manager.save(Entity2, data2);
});Critical mutations across multiple tables MUST be wrapped in transactions using DataSource.transaction.
// Missing DTO annotations
export class CreateDogDto { name: string; }Maintaining API documentation manually outside the codebase guarantees it will become outdated and inaccurate. This severely degrades the developer experience for frontend teams and third-party integrators.
export class CreateDogDto {
@ApiProperty({ example: 'Rex', description: 'The name of the dog' })
name: string;
}Document all DTO properties using @ApiProperty(). Swagger will automatically generate the schema.
// No protection against DDoS and brute force
Failing to follow best practices for rate limiting (throttlermodule) tightly couples dependencies and degrades predictability. This unstructured approach deviates from deterministic AI-coding standards, creating severe architectural debt and potential security vulnerabilities in enterprise scaling.
// app.module.ts
ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }])Always integrate @nestjs/throttler to protect the API from overload.
// Every request performs heavy DB calculations
Executing computationally expensive operations or querying the database for static data on every request creates severe performance bottlenecks. It needlessly wastes database resources and degrades overall system throughput.
@UseInterceptors(CacheInterceptor)
@CacheTTL(30) // 30 seconds
@Get('stats')
getStats() {}Cache heavy requests using CacheModule (in-memory or Redis) to offload the DB.
await this.userService.create();
await this.emailService.send(); // Tight dependency couplingSynchronously calling unrelated services (e.g., sending an email after user creation) creates tight coupling and increases request latency. This violates event-driven architecture principles, making the system less resilient to partial failures.
await this.userService.create();
this.eventEmitter.emit('user.created', new UserCreatedEvent(user));Use @nestjs/event-emitter. Services MUST NOT await email dispatch; they MUST simply publish an event.
setInterval(() => this.cleanupData(), 1000 * 60 * 60);Using standard setInterval for background tasks outside of the NestJS lifecycle prevents proper tracking and memory management. It WILL lead to memory leaks, duplicate task execution in clustered environments, and difficult testing.
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
handleCron() { this.cleanupData(); }Use @nestjs/schedule with declarative decorators for background tasks.
@Post() // Using HTTP for inter-service communicationUsing standard HTTP requests for internal microservice-to-microservice communication introduces significant network latency and point-to-point coupling. It prevents utilizing asynchronous, resilient message brokers like RabbitMQ or Kafka.
@MessagePattern({ cmd: 'get_user' })
getUser(data: unknown) { return this.userService.findById(data.id); }Use TCP, Redis, or RabbitMQ via @MessagePattern for microservice communication within the cluster.
@Get('ping') ping() { return 'pong'; }Using a simple 'ping' endpoint that always returns 200 OK does not verify the actual health of the application's dependencies (e.g., database connection). Orchestrators WILL mistakenly keep a broken container in the load balancer pool.
@Get('health')
@HealthCheck()
check() { return this.health.check([() => this.db.pingCheck('database')]); }Use @nestjs/terminus for deep health checks (DB, memory) for Kubernetes Liveness Probes.
// UserService -> AuthService -> UserService
@Injectable() class UserService { constructor(private auth: AuthService) {} }Circular dependencies (Module A imports Module B, which imports Module A) cause resolution failures during application bootstrap. They are a strong indicator of architectural design flaws and tightly coupled domain boundaries.
@Injectable() class UserService { constructor(@Inject(forwardRef(() => AuthService)) private auth: AuthService) {} }If the architecture forces a circular dependency, use forwardRef(); however, it is highly recommended to refactor the code.
// Module B imports Module A, Module C imports Module A...Duplicating module imports across the application instead of utilizing a structured Shared or Core module leads to excessive boilerplate and potential circular dependency issues. It makes the dependency graph unnecessarily complex.
@Module({ imports: [DatabaseModule], exports: [DatabaseModule] })
export class CoreModule {} // Global facadeUse module exports to create shared Core/Shared modules that encapsulate common logic.
// Defining request logger everywhere
Defining repetitive middleware logic (like request logging) locally in multiple controllers violates the DRY principle. It leads to inconsistent implementation and makes it difficult to enforce global policies.
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('*'); }
}Perform global operations prior to Guards (e.g., injecting Request IDs) via NestMiddleware.
const service = new UserService(new Database()); // Real DB in testsConnecting to a real database in unit tests makes tests slow, flaky, and dependent on external state. It violates the core principle of isolated unit testing, which MUST rely on mocked dependencies.
const module = await Test.createTestingModule({
providers: [UserService, { provide: getRepositoryToken(User), useValue: mockUserRepo }],
}).compile();All unit tests MUST use mock injection via Test.createTestingModule.
if (!isEmailUnique(dto.email)) throw error; // Manual logic in serviceImplementing validation logic inside business services rather than utilizing class-validator constraints tightly couples validation to execution. It bypasses the global validation pipe, leading to inconsistent error formatting.
@ValidatorConstraint({ async: true })
export class IsUniqueConstraint implements ValidatorConstraintInterface { ... }
@Validate(IsUniqueConstraint) email: string;Create custom rules for class-validator, including asynchronous checks (e.g., DB validation).
// Manual stream handling
Handling binary file streams manually without the built-in Multer interceptors is highly error-prone. It drastically increases the risk of memory exhaustion, incomplete uploads, and unhandled boundary parsing exceptions.
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {}The built-in FileInterceptor based on Multer is the standard for handling file uploads.
const { password, ...safeUser } = user; // Manual password removalManually deleting sensitive fields (like passwords) from response objects before returning them is error-prone. Forgetting to do this in even one place exposes sensitive data to the client, creating a critical security vulnerability.
class UserEntity { @Exclude() password: string; }
@UseInterceptors(ClassSerializerInterceptor) // Auto-cleanup
@Get() getUser() { return new UserEntity(data); }Use @Exclude() from class-transformer along with ClassSerializerInterceptor to hide sensitive fields.
// Calling specific methods req.expressMethod
Directly accessing platform-specific request/response objects (like Express's req.ip) tightly couples the application to the underlying HTTP framework. This prevents easy migration to higher-performance engines like Fastify.
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());Write platform-agnostic code. Nest easily switches from Express to Fastify for extreme performance if needed.
// Application is killed instantly, dropping active connections
Immediate process termination severs active connections and leaves database operations in an unknown state. This causes data corruption and forces clients to experience unhandled connection drops.
app.enableShutdownHooks();
@Injectable() class MyService implements OnApplicationShutdown { onApplicationShutdown() { /* Close connections */ } }Call enableShutdownHooks() to catch SIGINT/SIGTERM and safely terminate database processes.
Explore advanced architectural topics for NestJS: