NestJS patterns -- modules, DI, exception filters, validation pipes, guards, interceptors, testing, config
98
89%
Does it follow best practices?
Impact
100%
1.36xAverage score across 12 eval scenarios
Passed
No known issues
Patterns for NestJS -- modules, dependency injection, exception filters, validation, guards, interceptors, custom providers, testing, and configuration.
src/
├── main.ts # Bootstrap + global pipes/filters/interceptors
├── app.module.ts # Root module
├── menu/
│ ├── menu.module.ts # Feature module
│ ├── menu.controller.ts # HTTP layer
│ ├── menu.service.ts # Business logic
│ └── dto/
│ └── menu-item.dto.ts # Request/response shapes
├── orders/
│ ├── orders.module.ts
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ └── dto/
│ ├── create-order.dto.ts
│ └── order-response.dto.ts
├── common/
│ ├── filters/
│ │ └── all-exceptions.filter.ts
│ ├── guards/
│ │ └── auth.guard.ts
│ └── interceptors/
│ └── logging.interceptor.ts
└── config/
└── config.module.tsEach feature is a module with its own controller, service, and DTOs. Cross-cutting concerns go in common/. Configuration goes in config/.
// dto/create-order.dto.ts
import { IsString, IsArray, IsInt, Min, Max, ValidateNested, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
class OrderItemDto {
@IsInt() @Min(1) menuItemId: number;
@IsString() size: string;
@IsInt() @Min(1) @Max(20) quantity: number;
}
export class CreateOrderDto {
@IsString() @MinLength(1) customerName: string;
@IsArray() @ValidateNested({ each: true }) @Type(() => OrderItemDto)
items: OrderItemDto[];
}@ValidateNested AND @Type// WRONG -- missing @Type means nested objects are NOT validated
@IsArray() @ValidateNested({ each: true })
items: OrderItemDto[];
// RIGHT -- @Type from class-transformer enables nested transformation and validation
@IsArray() @ValidateNested({ each: true }) @Type(() => OrderItemDto)
items: OrderItemDto[];@Post()
async create(@Body() dto: CreateOrderDto) {
// dto is already validated -- invalid requests return 400 automatically
return { data: await this.ordersService.create(dto) };
}{ data: ... } envelope// WRONG -- returning raw result
@Post()
async create(@Body() dto: CreateOrderDto) {
return this.ordersService.create(dto);
}
// RIGHT -- wrap in data envelope for consistent API shape
@Post()
async create(@Body() dto: CreateOrderDto) {
return { data: await this.ordersService.create(dto) };
}
// RIGHT -- GET endpoints too
@Get()
async findAll() {
return { data: await this.ordersService.findAll() };
}
@Get(':id')
async findOne(@Param('id') id: string) {
return { data: await this.ordersService.findOne(id) };
}main.ts// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation -- whitelist strips unknown properties, transform auto-converts types
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}));
// Global exception filter for consistent error envelope
app.useGlobalFilters(new AllExceptionsFilter());
await app.listen(3000);
}
bootstrap();Key ValidationPipe options:
whitelist: true -- strips properties not in the DTO (prevents mass assignment)transform: true -- auto-converts query/path params from strings to their DTO typesforbidNonWhitelisted: true -- returns 400 if unknown properties are sent (stricter than whitelist alone)// WRONG: returns { statusCode, timestamp, path } -- no consistent envelope
@Catch()
export class BadExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: ctx.getRequest().url,
});
}
}{ error: { code, message } } envelope// common/filters/all-exceptions.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
if (exception instanceof HttpException) {
const status = exception.getStatus();
response.status(status).json({
error: { code: exception.name, message: exception.message },
});
} else {
// IMPORTANT: Always log unknown errors before returning 500
console.error('Unhandled exception:', exception);
response.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
});
}
}
}Key rules for exception filters:
@Catch() with NO arguments to catch ALL exception types (not just HttpException){ error: { code: string, message: string } } for BOTH HttpException and unknown errorsconsole.error('Unhandled exception:', exception) for non-HttpException errors before returning the 500 responseapp.useGlobalFilters(new AllExceptionsFilter())@Injectable()
export class OrdersService {
constructor(
private readonly menuService: MenuService,
private readonly pricingService: PricingService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreateOrderDto): Promise<Order> {
const total = await this.pricingService.calculateTotal(dto.items);
const order = { ...dto, total, id: Date.now().toString() };
await this.notificationsService.sendConfirmation(order);
return order;
}
}// WRONG: Creating dependencies with new -- bypasses DI, breaks testing
@Injectable()
export class OrdersService {
private pricingService = new PricingService();
}// RIGHT: NestJS injects via constructor -- testable, swappable
@Injectable()
export class OrdersService {
constructor(private readonly pricingService: PricingService) {}
}Key rules:
new)Request or Response objects into services -- this couples them to HTTP and makes them untestable@Injectable()When you need more control over how a provider is created, use custom provider syntax:
// config/config.module.ts
@Module({
providers: [
// useValue -- provide a static value or config object
{
provide: 'APP_CONFIG',
useValue: { apiUrl: 'https://api.example.com', timeout: 5000 },
},
// useFactory -- create a provider with async logic or other dependencies
{
provide: 'DATABASE_CONNECTION',
useFactory: async (configService: ConfigService) => {
const connection = await createConnection(configService.get('DATABASE_URL'));
return connection;
},
inject: [ConfigService],
},
// useClass -- swap implementations (e.g., for testing or environment-specific behavior)
{
provide: 'LOGGER',
useClass: process.env.NODE_ENV === 'production' ? ProductionLogger : DevLogger,
},
],
exports: ['APP_CONFIG', 'DATABASE_CONNECTION', 'LOGGER'],
})
export class ConfigModule {}Inject token-based providers using @Inject():
@Injectable()
export class OrdersService {
constructor(
@Inject('APP_CONFIG') private readonly config: AppConfig,
@Inject('DATABASE_CONNECTION') private readonly db: Connection,
) {}
}When two modules depend on each other, use forwardRef to break the cycle:
// orders/orders.module.ts
@Module({
imports: [forwardRef(() => MenuModule)],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
// menu/menu.module.ts
@Module({
imports: [forwardRef(() => OrdersModule)],
providers: [MenuService],
exports: [MenuService],
})
export class MenuModule {}Also use forwardRef at the injection point:
@Injectable()
export class OrdersService {
constructor(
@Inject(forwardRef(() => MenuService))
private readonly menuService: MenuService,
) {}
}Prefer restructuring over forwardRef -- if two services depend on each other, consider extracting shared logic into a third service. Only use forwardRef when the circular dependency is truly unavoidable.
NestJS processes requests in this exact order:
Middleware → Guards → Interceptors (before) → Pipes → Route Handler → Interceptors (after) → Exception Filters@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) return false;
return this.authService.validateToken(token);
}
}
// Apply to a controller or method
@UseGuards(AuthGuard)
@Controller('orders')
export class OrdersController { ... }@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
console.log(`${request.method} ${request.url} ${duration}ms`);
}),
);
}
}@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const correlationId = req.headers['x-correlation-id'] || randomUUID();
req['correlationId'] = correlationId;
res.setHeader('x-correlation-id', correlationId);
next();
}
}
// Register in module
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
}
}When to use which:
import { Test, TestingModule } from '@nestjs/testing';
describe('OrdersService', () => {
let service: OrdersService;
let pricingService: jest.Mocked<PricingService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: PricingService,
useValue: {
calculateTotal: jest.fn().mockResolvedValue(25.00),
},
},
{
provide: NotificationsService,
useValue: {
sendConfirmation: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get<OrdersService>(OrdersService);
pricingService = module.get(PricingService) as jest.Mocked<PricingService>;
});
it('should create an order with calculated total', async () => {
const dto = { customerName: 'Alice', items: [{ menuItemId: 1, size: 'L', quantity: 2 }] };
const result = await service.create(dto);
expect(result.total).toBe(25.00);
expect(pricingService.calculateTotal).toHaveBeenCalledWith(dto.items);
});
});Key testing rules:
Test.createTestingModule to create an isolated module for unit testsuseValue providing jest.fn() stubsmodule.get<T>(Token) to retrieve the service under test// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
// Other feature modules...
],
})
export class AppModule {}Access config in services via ConfigService:
@Injectable()
export class DatabaseService {
constructor(private readonly configService: ConfigService) {}
getConnectionString(): string {
return this.configService.get<string>('DATABASE_URL');
}
}Key rules:
isGlobal: true on ConfigModule so all modules can access ConfigService without re-importingconfigService.get<string>('KEY') with a type parameter for type safetyprocess.env directly in services -- always use ConfigService for testability@ValidateNested({ each: true }) AND @Type(() => NestedDto)whitelist: true and transform: true@Catch() with no arguments (catches ALL exceptions){ error: { code, message } } envelope for all errorsconsole.error for non-HttpException errors before returning 500{ data: result } envelopenew)Request/Response objects in servicesTest.createTestingModule with mocked providersisGlobal: true, services use ConfigService (not process.env)evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
nestjs-best-practices
verifiers