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/
httpservice, 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 orDataSourceprovider 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:
exportsis the module's public API. A provider not exported is private to that module — that is the encapsulation, lean on it.importsbrings 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
AppModulethin. It wires feature modules and global config, nothing else. A godAppModulethat 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. Keeptscfor 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 newhistorically 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.