| name | nest-conventions |
| description | NestJS module structure, dependency injection, lifecycle, configuration, exception handling, and logging conventions. Apply when implementing or modifying NestJS backend code.
Use this skill to:
- Structure feature modules (one feature = one module).
- Wire DI correctly (constructor injection, scopes, custom tokens).
- Set up ConfigModule, ValidationPipe, exception filters in main.ts.
- Use lifecycle hooks for init and graceful shutdown.
- Pick the right exception class for each error case.
Do NOT use this skill for:
- Decorator-specific patterns (see decorator-patterns).
- ORM/data access (see nest-data-layer).
- GraphQL/WebSockets/Microservices (see nest-advanced).
- Testing (see nest-testing).
|
NestJS Conventions
This skill encodes module-level idioms for NestJS backend code. Apply alongside decorator-patterns (decorator-specific syntax) and js-foundation:typescript-patterns (general TS strictness).
Project layout
Standalone application:
project-root/
├── nest-cli.json
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── src/
│ ├── main.ts # bootstrap
│ ├── app.module.ts # root module
│ ├── app.controller.ts # optional, often just /healthz
│ ├── app.service.ts # optional
│ ├── config/ # config files (app.config.ts, db.config.ts)
│ ├── common/ # shared (filters, interceptors, pipes, decorators)
│ │ ├── filters/
│ │ ├── interceptors/
│ │ ├── pipes/
│ │ └── decorators/
│ ├── 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
│ │ └── tests/ # often co-located OR in /test
│ ├── auth/
│ └── ...
├── test/ # e2e tests
│ └── *.e2e-spec.ts
└── dist/ # build output, gitignored
Monorepo (Nx-flavored, nest-cli.json "monorepo": true):
project-root/
├── nest-cli.json
├── apps/
│ └── api/
│ └── src/
└── libs/
├── shared/
└── users/
Mirror what exists. Don't introduce a new layout pattern for one feature.
Module structure
One feature = one module:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Wire into root:
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({...}),
UsersModule,
AuthModule,
],
})
export class AppModule {}
@Global() sparingly
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
Use only for genuinely cross-cutting deps (logger, config, DB connection). For two unrelated features needing the same service — explicit import is clearer.
Circular imports — refactor first
If UsersModule and OrdersModule import each other:
- Extract shared types to a
shared/ lib.
- Or merge them if conceptually one bounded context.
- Last resort:
forwardRef(() => OtherModule) with a comment explaining the dependency cycle.
imports: [forwardRef(() => OrdersModule)]
Dependency injection
Constructor injection
@Injectable()
export class UsersService {
constructor(
private readonly users: Repository<User>,
private readonly mailer: MailerService,
@Inject('AUDIT_LOG') private readonly audit: AuditLog,
) {}
}
private readonly — DI props don't get reassigned.
- Type-based tokens for classes; string/symbol tokens for non-class values via
@Inject('TOKEN').
Custom providers
@Module({
providers: [
UsersService,
{
provide: 'AUDIT_LOG',
useClass: ProductionAuditLog,
},
{
provide: 'CONFIG_OPTIONS',
useFactory: (config: ConfigService) => ({
retries: config.get<number>('RETRIES', 3),
}),
inject: [ConfigService],
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
useClass — bind interface to implementation.
useValue — static value (constants, configs).
useFactory — runtime computation; declare inject for factory deps.
useExisting — alias another provider.
Scopes
@Injectable({ scope: Scope.DEFAULT })
@Injectable({ scope: Scope.REQUEST })
@Injectable({ scope: Scope.TRANSIENT })
Scope.REQUEST is contagious — anything injecting a request-scoped provider becomes request-scoped too. Use only when you genuinely need per-request state (e.g., request-bound logger context). Costly otherwise.
Configuration
ConfigModule setup
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').required(),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(),
}),
validationOptions: { abortEarly: false },
}),
],
})
If validationSchema fails on boot, the app crashes loud — exactly what you want.
Inject ConfigService
@Injectable()
export class DatabaseService {
constructor(private readonly config: ConfigService) {
const url = this.config.get<string>('DATABASE_URL', { infer: true });
}
}
infer: true — TypeScript infers return type from schema if you typed it.
- Provide default as 2nd arg only when truly optional:
config.get('CACHE_TTL', 60).
- Never
process.env.X outside config/ setup files.
Per-feature config
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432', 10),
url: process.env.DATABASE_URL,
}));
@Injectable()
class DbModuleOptions {
constructor(@Inject(databaseConfig.KEY) private cfg: ConfigType<typeof databaseConfig>) {}
}
Validation pipe (global)
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}));
await app.listen(3000);
}
bootstrap();
Without whitelist + forbidNonWhitelisted, extra payload fields silently pass through — that's a real source of mass-assignment bugs.
Exception handling
Built-in HTTP exceptions
throw new BadRequestException('email already in use');
throw new UnauthorizedException();
throw new ForbiddenException('insufficient role');
throw new NotFoundException(`user ${id} not found`);
throw new ConflictException('version mismatch');
throw new UnprocessableEntityException(...);
throw new InternalServerErrorException();
For non-HTTP layers (microservice handlers, WebSocket gateways), throw plain Error subclasses; map at the boundary via filters or transports' built-in error handling.
Custom exception filter
@Catch(MyDomainException)
export class MyDomainExceptionFilter implements ExceptionFilter {
catch(exception: MyDomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(exception.statusCode).json({
error: exception.code,
message: exception.message,
});
}
}
Apply via @UseFilters(MyDomainExceptionFilter) per controller/route, or globally:
app.useGlobalFilters(new MyDomainExceptionFilter());
Catch-all filter for logging
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
this.logger.error('unhandled', exception);
}
}
Add LAST in useGlobalFilters order (innermost catch-all).
Lifecycle hooks
@Injectable()
export class WarmupService implements OnModuleInit, OnApplicationShutdown {
async onModuleInit() {
await this.warmCache();
}
async onApplicationShutdown(signal?: string) {
this.logger.log(`shutting down: ${signal}`);
await this.cleanup();
}
}
Enable shutdown hooks in main:
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
Order:
OnModuleInit — after module's deps ready, before app boots.
OnApplicationBootstrap — after ALL modules ready.
OnModuleDestroy / OnApplicationShutdown — graceful shutdown on SIGTERM/SIGINT.
Logging
Built-in Logger:
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
async create(dto: CreateUserDto) {
this.logger.log(`creating user ${dto.email}`);
try {
} catch (err) {
this.logger.error('create failed', err);
throw err;
}
}
}
For structured production logs, swap globally:
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
app.useLogger(new MyStructuredLogger());
Anti-patterns
- ❌
new MyService() outside test files — always inject.
- ❌
process.env.X in business code — use ConfigService.
- ❌ Global ValidationPipe disabled "for performance" — measure first.
- ❌ Catch-all
try/catch swallowing errors silently.
- ❌ Mutating injected services (
this.someInjectedService.field = newValue).
- ❌ Logic in controllers (anything beyond binding params and calling service).
- ❌ Importing controllers from other modules.
- ❌
@Global() on every shared module — defeats explicit-imports clarity.
- ❌ Using
forwardRef to "fix" a real coupling problem.
- ❌ Module-level mutable state (no static class fields with non-readonly types).