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 with Test.createTestingModule. NOT a bare Express/Fastify service with no DI (that is `nodejs`), NOT framework-agnostic REST design (that is `api-design`).
npx skills add https://github.com/ericrisco/rsc-harness --skill 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.
http service, no @Module/@Injectable → ../nodejs/SKILL.md. Nest starts the moment the DI container appears.../api-design/SKILL.md. Nest is where you *implement* those decisions.../prisma-orm/SKILL.md. *Injecting* a repo or DataSource provider stays here; designing the table does not.../testing-web/SKILL.md. The Nest harness (TestingModule, overrideProvider, Supertest bootstrap) stays here.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.
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.AppModule thin. It wires feature modules and global config, nothing else. A god AppModule that declares every controller becomes an untestable circular-dependency magnet.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 {}
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.
| 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.
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.
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).
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.
nest g resource orders, nest g module orders, nest g service orders. It wires the module registration for you.tsc. Keep tsc for type-checking in CI.nest new historically scaffolds.| 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 |
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.
Take ericrisco/nestjs from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.