nest-conventions

NestJS module structure, dependency injection, lifecycle, configuration, exception handling, and logging conventions. Apply when implementing or modifying NestJS backend code. Use this skill to: - St

SKILL.md

GitHub

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:

// src/users/users.module.ts
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 only what other modules need
})
export class UsersModule {}

Wire into root:

// src/app.module.ts
@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:

  1. Extract shared types to a shared/ lib.
  2. Or merge them if conceptually one bounded context.
  3. Last resort: forwardRef(() => OtherModule) with a comment explaining the dependency cycle.
imports: [forwardRef(() => OrdersModule)]
// circular: User has many Orders; Order belongs to User. Type-only import in service.

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 })  // singleton (implicit default)
@Injectable({ scope: Scope.REQUEST })  // per-request — propagates UP the dep chain
@Injectable({ scope: Scope.TRANSIENT }) // new instance per inject

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

// src/config/database.config.ts
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,
}));

// inject in feature
@Injectable()
class DbModuleOptions {
  constructor(@Inject(databaseConfig.KEY) private cfg: ConfigType<typeof databaseConfig>) {}
}

Validation pipe (global)

// src/main.ts
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,                  // strip unknown fields
    forbidNonWhitelisted: true,       // reject requests with unknown fields
    transform: true,                  // class-transformer auto-cast
    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();                       // 401
throw new ForbiddenException('insufficient role');       // 403
throw new NotFoundException(`user ${id} not found`);     // 404
throw new ConflictException('version mismatch');         // 409
throw new UnprocessableEntityException(...);             // 422
throw new InternalServerErrorException();                // 500 (last resort)

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);
    // re-throw or format default response
  }
}

Add LAST in useGlobalFilters order (innermost catch-all).

Lifecycle hooks

@Injectable()
export class WarmupService implements OnModuleInit, OnApplicationShutdown {
  async onModuleInit() {
    // run after this module's providers instantiated
    await this.warmCache();
  }
  async onApplicationShutdown(signal?: string) {
    // close DB pool, drain queues
    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:

// option A: nestjs-pino
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));

// option B: custom logger implementing LoggerService
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).