nestjs

Use when building or structuring a NestJS backend — feature modules, providers and DI wiring, provider scopes and request-lifecycle order, where to bind guards/pipes/interceptors/filters, and testing

SKILL.md

GitHub

NestJS

Build server-side Node apps the way NestJS intends: feature modules, providers wired through the DI container, controllers, and a cross-cutting layer bound at a deliberate scope. This skill is about Nest-specific mechanics — how DI scopes resolve, what order the request lifecycle runs in, where to bind guards/pipes/interceptors/filters, and how to test it with Test.createTestingModule.

Not this — route instead

  • Bare Express/Fastify/http service, no @Module/@Injectable → ../nodejs/SKILL.md. Nest starts the moment the DI container appears.
  • REST resource modeling, versioning, status codes, idempotency (framework-agnostic) → ../api-design/SKILL.md. Nest is where you implement those decisions.
  • Designing the schema / writing queries / migrations → ../prisma-orm/SKILL.md. Injecting a repo or DataSource provider stays here; designing the table does not.
  • Generic JS/TS test infra (Jest config, coverage thresholds, monorepo) → ../testing-web/SKILL.md. The Nest harness (TestingModule, overrideProvider, Supertest bootstrap) stays here.

Mental model

Everything is a provider in a directed DI graph. Modules draw the boundaries of that graph — a provider is only reachable where it is provided or imported. Cross-cutting concerns (guards, interceptors, pipes, filters) are decorators bound at a scope you choose: global, controller, or route. Get those three right and the rest is plumbing.

The request lifecycle runs in a fixed order. Memorize it — most "my guard can't see the validated body" bugs are an ordering misunderstanding:

req → middleware → guards → interceptors(pre) → pipes → handler → interceptors(post) → exception filters → res

So pipes run after guards (a guard cannot read a transformed DTO), and filters catch everything thrown downstream. Source: NestJS request-lifecycle docs.

Module design

One feature module per bounded context. Rules, each with its why:

  • exports is the module's public API. A provider not exported is private to that module — that is the encapsulation, lean on it.
  • imports brings in another module's exports; it does not re-declare providers. Re-declaring a provider in two modules gives you two singletons and silent state bugs.
  • Keep AppModule thin. It wires feature modules and global config, nothing else. A god AppModule that declares every controller becomes an untestable circular-dependency magnet.
  • Use a dynamic module (forRoot / forRootAsync) for configurable infrastructure (DB, cache, mailer) so consumers pass options instead of editing the module.
// Bad — everything dumped in AppModule, no boundaries
@Module({ controllers: [OrdersController, UsersController, BillingController],
          providers: [OrdersService, UsersService, BillingService, PrismaService] })
export class AppModule {}

// Good — a feature module owns its slice and exports only its public surface
@Module({
  imports: [PrismaModule],
  controllers: [OrdersController],
  providers: [OrdersService],
  exports: [OrdersService], // other modules consume the service, not the repo
})
export class OrdersModule {}

Providers & DI

Pick the custom-provider form by intent:

Form Use it when Resolved by
useClass Default — swap implementation by class (e.g. real vs fake mailer) Nest instantiates
useValue A ready object/constant: config, a mock in tests Used as-is
useFactory Value needs computing or other providers (inject: [...]) Your factory fn
useExisting Alias an existing token to a new token Reuses instance

A non-class token needs explicit injection — Nest has no type to reflect on:

const STRIPE = 'STRIPE_CLIENT';

@Module({
  providers: [{
    provide: STRIPE,
    useFactory: (cfg: ConfigService) => new Stripe(cfg.get('STRIPE_KEY')),
    inject: [ConfigService],
  }],
  exports: [STRIPE],
})
export class PaymentsModule {}

@Injectable()
export class CheckoutService {
  constructor(@Inject(STRIPE) private readonly stripe: Stripe) {}
}

forwardRef(() => X) is a last resort, not a fix — it works around a circular dependency that usually signals two modules that should share a third. Reach for the refactor first; if you must, forwardRef goes on both sides. See the anti-patterns table.

Provider scopes

Scope Lifetime Use when
DEFAULT Singleton (one per app) Almost always — stateless services
REQUEST New instance per request You genuinely need per-request state (@Inject(REQUEST) for the live request)
TRANSIENT New instance per consumer Each injector gets its own copy

REQUEST scope bubbles up: any provider that injects a request-scoped provider becomes request-scoped too, and so does the controller — with a real per-request instantiation cost. Default to singleton; reach for REQUEST only when you must.

@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
  constructor(@Inject(REQUEST) private readonly req: Request) {}
  get userId() { return this.req.user?.id; }
}

The classic gotcha: a request-scoped provider injected into a guard reads as undefined or stale because guards run early and the scope propagation is not what you assumed. If a guard needs request data, pull it from ExecutionContext (context.switchToHttp().getRequest()), not from an injected request-scoped service.

Cross-cutting layer

Pick the primitive by what it is for:

Primitive Job Signature
Guard Authorize — allow/deny the request returns boolean / Promise<boolean>
Interceptor Wrap the handler before and after (logging, transform, timeout, cache) RxJS, handle().pipe(...)
Pipe Validate and/or transform an input argument returns transformed value or throws
Exception filter Catch a thrown error and shape the response catch(exception, host)

Then pick the binding scope:

Binding Reach Can inject deps?
APP_GUARD / APP_PIPE / APP_INTERCEPTOR / APP_FILTER token in a module's providers Global Yes — resolved by the DI container
@UseGuards(X) / @UsePipes(X) on controller or route Local Yes if you pass the class
app.useGlobalGuards(new X()) in main.ts Global No — you instantiated it yourself

The gotcha that bites everyone: app.useGlobalPipes(new ValidationPipe()) works, but a guard or pipe that needs to inject a ConfigService cannot be registered with new — Nest never resolved it. Use the APP_* token instead so the container builds it:

// Good — global AND DI-capable
@Module({
  providers: [{ provide: APP_GUARD, useClass: AuthGuard }],
})
export class AppModule {}

Deeper material — ExecutionContext, custom param decorators, Reflector + SetMetadata for role/@Public() guards, transform/timeout interceptors, filter shape, multiple-binding order — is in references/cross-cutting.md.

Validation

DTO + ValidationPipe + class-validator/class-transformer. The production-safe config:

// main.ts
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,            // strip properties with no decorator
  forbidNonWhitelisted: true, // 400 on unknown properties instead of silently dropping
  transform: true,            // coerce payloads to DTO class instances (and primitives)
}));
export class CreateOrderDto {
  @IsString() @IsNotEmpty()
  sku: string;

  @IsInt() @Min(1)
  quantity: number;
}

If the pipe needs to inject something, bind it globally via APP_PIPE instead of new (same DI rule as above).

Testing

Test.createTestingModule({...}).compile() returns a TestingModule you pull providers from. Decision line: unit-test a provider with its collaborators mocked; e2e-test the wired app over HTTP.

Unit — mock the collaborators:

const moduleRef = await Test.createTestingModule({
  providers: [OrdersService],
})
  .overrideProvider(OrderRepository)
  .useValue({ findById: vi.fn().mockResolvedValue(order) })
  .compile();

const service = moduleRef.get(OrdersService);

e2e — boot the real app and hit it with Supertest. Replicate the global config from main.ts (pipes, filters, guards) or the test passes while prod 400s:

const app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); // mirror main.ts
await app.init();
await request(app.getHttpServer()).post('/orders').send(body).expect(201);

overrideGuard(AuthGuard).useValue({ canActivate: () => true }) lets an e2e test bypass auth. Full recipes — mocking a repo, request-scoped via resolve(), ConfigModule in tests, Vitest vs Jest — in references/testing-recipes.md.

Bootstrap & tooling

  • Scaffold with the CLI, not by hand: nest g resource orders, nest g module orders, nest g service orders. It wires the module registration for you.
  • NestJS 11 is current (Jan 2025), requires Node.js 20+, and ships Express v5 as the default HTTP adapter (Fastify remains an option).
  • Use the SWC builder for dev — roughly 20x faster builds and faster cold start than tsc. Keep tsc for type-checking in CI.
  • ESM is first-class in v11 (top-level await aligned with modern Node).
  • The official harness is moving toward Vitest (SWC-powered) alongside Jest; Jest is still fully supported and is what nest new historically scaffolds.

Anti-patterns

Anti-pattern Why it hurts Do instead
God AppModule declaring every controller/provider No boundaries; breeds circular deps; untestable One feature module per bounded context, exports = public API
new OrdersService(repo) inside a controller/service Bypasses DI; same class becomes two unmocked instances Constructor-inject; let the container build it
Business logic in the controller Controllers should map HTTP ↔ service calls only Push logic into a provider; controller stays thin
Scope.REQUEST by default Bubbles up the chain, per-request cost, surprise undefined in guards Default singleton; REQUEST only with a real reason
useGlobalPipes(new X()) for a pipe that needs deps new is not DI-resolved; injected deps are undefined Bind via APP_PIPE / APP_GUARD token in providers
e2e test that skips main.ts globals Green test, red prod — validation/filters not applied Replicate global pipes/filters/guards in the e2e bootstrap
forwardRef sprinkled to silence "circular dependency" Hides the real coupling; fragile bootstrap order Refactor to a shared module; forwardRef only as last resort, on both sides

Verify

Run scripts/verify.sh [dir] to statically catch DI bypasses without a Nest install. It hard-fails only on new XxxService(...) outside test files; everything else is a warning. Exits 0 on a clean or empty target.