Framework · TypeScript 6 · NestJS 11 · Node 24 LTS · Prisma 7
NestJS
Modules, DI and DTOs — a typed, layered Node backend.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level NestJS engineer. Ship modular, strongly-typed HTTP/RPC services: thin controllers, logic in injectable services, every input validated at the boundary, entities never leaked, and no circular module graphs. "Good" means it compiles under strict TypeScript, passes lint, has focused tests, and fails safe.
Stack
- Node.js 24 LTS (Active LTS). Do not target
Current(26) or EOL lines for prod.engines.node: ">=22"minimum (Node 20 hit EOL 2026-04-30 — don't let the floor admit an unsupported runtime); develop on 24. - NestJS 11 (
@nestjs/common/@nestjs/core^11.1). Runs on Express 5 by default (the Nest 11 platform default). Fastify (@nestjs/platform-fastify^11) only when you need the throughput and accept its plugin model. - TypeScript 6.0 (
typescript@~6.0) withstrict: true. TS 7 (the Go compiler) is not GA yet — do not pin it in a service. - Package manager: pnpm (or npm) with a committed lockfile. Node's built-in
.envsupport does not replace@nestjs/config. - Validation:
class-validator@^0.15+class-transformer@^0.5with a globalValidationPipe, orzod@^4.4+nestjs-zod@^5.4(ZodValidationPipe). Pick one per project; do not mix DTO styles. - Config:
@nestjs/config@^4with a validation schema (Zod or Joi). - ORM: Prisma 7 (
prisma/@prisma/client^7.8, TypeScript runtime, no Rust engine) for new work; or TypeORM 1.0 (typeorm@^1.0) if the team already runs it. Both live in the repository/service layer only. - Auth:
@nestjs/jwt@^11,@nestjs/passport@^11+passport-jwt, or a custom guard. Hash withargon2@^0.44(preferred) orbcrypt. - Docs:
@nestjs/swagger@^11(OpenAPI 3.2 — callsetOpenAPIVersion('3.2.0')if you use hierarchical tags). Logging:nestjs-pino@^4+pino@^10. Rate limit:@nestjs/throttler@^6. Security headers:helmet@^8. - Testing: Jest 30 (
jest@^30) via@nestjs/testing,supertest@^7for e2e,@swc/jestfor fast transform. - Tooling:
@nestjs/cli@^11, ESLint 10 flat config (eslint.config.mjs; eslintrc is removed in v10) +typescript-eslint@^8, Prettier 3,@swc/corefor build/watch.
Project conventions
- File names are kebab-case with a role suffix:
users.module.ts,users.controller.ts,users.service.ts,create-user.dto.ts,user.entity.ts,jwt-auth.guard.ts,logging.interceptor.ts,all-exceptions.filter.ts,users.service.spec.ts,users.e2e-spec.ts. One primary class per file. - Folder layout: feature-first.
src/ main.ts app.module.ts config/ # registerAs() namespaces + schema common/ # guards, interceptors, filters, decorators, pipes (cross-cutting) prisma/ # PrismaService (or database/ for TypeORM DataSource) users/ users.module.ts users.controller.ts users.service.ts dto/ entities/ users.service.spec.ts - Generate with schematics:
nest g module users,nest g service users. Do not hand-scaffold wiring you can generate. - Imports: use the path alias
@/*(tsconfigpaths+baseUrl) for cross-feature imports; relative paths within a feature. No deep imports into another feature's internals — import via its module's exports. - tsconfig:
strict: true,noUncheckedIndexedAccess: true,exactOptionalPropertyTypes: true,forceConsistentCasingInFileNames: true. KeepemitDecoratorMetadataandexperimentalDecoratorson (class-validator/TypeORM need them). - No
any. Useunknownat boundaries and narrow. No// @ts-ignorewithout a one-line justification and a linked issue. - Format/lint are non-negotiable gates:
eslint . --max-warnings=0andprettier --checkin CI.
Module structure
- Feature modules own their surface. A module declares
controllers,providers, andexportsonly the providers other modules legitimately consume. Never export a provider just to make a test pass — inject it in the test module instead. importsfor dependencies,exportsfor the public API. If module B needs A's service, A mustexportit and B mustimport: [AModule]. Do not re-provide the same class in two modules (creates two instances).- Dynamic modules (
forRoot/forRootAsync,forFeature) for configurable infra (DB, cache, config). Mark truly global cross-cutting modules@Global()sparingly (config, Prisma) — overusing@Global()hides the dependency graph. - Circular module deps are a design smell, not a
forwardRef()opportunity. Break the cycle: extract the shared provider/types into a third module both depend on. UseforwardRef()only as a last resort and leave a comment explaining why.
Dependency injection & scopes
- Constructor injection with
private readonly:constructor(private readonly users: UsersService, private readonly config: ConfigService) {} - Program to tokens for swappable deps: define an
abstract class/interface token andprovide: MAILER, useClass: SesMailer. Inject with@Inject(MAILER). This keeps services testable without touching concrete infra. - Default scope is singleton — keep it.
Scope.REQUESTinstantiates the provider (and everything that injects it) per request and poisons performance up the chain; use it only when you genuinely need per-request state, and preferClsService(nestjs-cls) or the execution context for request data instead. - Never do work in a constructor. Use
onModuleInit()for async setup (DB connect, warmup). Constructors only assign injected deps.
Controllers stay thin
- Controllers do routing, DTO binding, and delegation — zero business logic, zero DB access, zero branching on domain state. All of that lives in a service.
@Post() create(@Body() dto: CreateUserDto): Promise<UserResponseDto> { return this.users.create(dto); } - Return values; do not use
@Res()passthrough unless you must stream/set raw headers — grabbing the response object bypasses interceptors, serialization, and the exception filter. If you truly need it, use@Res({ passthrough: true }). - Enable global URI versioning (
app.enableVersioning({ type: VersioningType.URI })) and version controllers (@Controller({ path: 'users', version: '1' })) rather than baking/v1into strings.
DTOs & validation — never trust the raw body
- Register the global
ValidationPipeonce inmain.ts:
Keepapp.useGlobalPipes(new ValidationPipe({ whitelist: true, // strip unknown props forbidNonWhitelisted: true, // 400 on unknown props transform: true, // instantiate the DTO class transformOptions: { enableImplicitConversion: false }, }));enableImplicitConversion: falseand convert explicitly with@Type(() => Number)— implicit conversion silently coerces and hides bugs. - Every DTO is a class with decorators; the pipe only validates typed class params:
export class CreateUserDto { @IsEmail() email!: string; @IsString() @MinLength(12) password!: string; @IsOptional() @IsInt() @Min(0) age?: number; } - Validate query and params too (
@Query(),@Param()DTOs), not just@Body(). Use@ParseIntPipe/@ParseUUIDPipefor scalar path params. - Zod alternative: define a schema, wrap with
createZodDto(schema), registerZodValidationPipeglobally viaAPP_PIPE, and callpatchNestJsSwagger()for OpenAPI. One validation stack per project. - Response DTOs are mandatory — controllers return a mapped response type (or a
classwith@Expose), never the persistence entity (see below).
Guards, interceptors, filters
- Guards for authN/authZ. Implement
CanActivate, read metadata withReflector.getAllAndOverride(...)(handler + class). Wire a global auth guard viaAPP_GUARDand opt public routes out with a@Public()decorator (SetMetadata('isPublic', true)) rather than adding the guard to every controller. - Interceptors for cross-cutting response/timing/caching — a
TransformInterceptorfor the standard envelope, request-timing logs. UseClassSerializerInterceptoronly alongside explicit response DTOs, never as a substitute for mapping. - One global exception filter (
APP_FILTER) producing a stable error shape:
Map known domain/ORM errors (e.g. Prisma{ "statusCode": 409, "error": "Conflict", "message": "Email already used", "requestId": "..." }P2002→ 409,P2025→ 404) toHttpExceptions; log unknowns aterrorwith the requestId and return a generic 500 — never leak stack traces or SQL to clients. - Register cross-cutting providers with
APP_GUARD/APP_INTERCEPTOR/APP_FILTER/APP_PIPEtokens (DI-aware) rather thanapp.useGlobal*when the component needs injected dependencies.
Configuration
ConfigModule.forRoot({ isGlobal: true, validate })with a schema that fails fast on boot:const Env = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), }); export const validate = (raw: Record<string, unknown>) => Env.parse(raw);- Namespaced config with
registerAsand typed access:configService.get('jwt.secret', { infer: true })usingConfigService<Config, true>. Do not scatterprocess.env.Xreads through services — read env only in the config layer. - Never commit
.env. Provide.env.example. Secrets come from the environment/secret manager in prod.
Persistence — entities never cross the controller boundary
- Prisma 7: one
PrismaService extends PrismaClientwithonModuleInit()→$connect()andonModuleDestroy()→$disconnect(); callapp.enableShutdownHooks()inmain.ts(Prisma removed$on('beforeExit')). Use theprisma-clientgenerator with an explicitoutput. All queries go through repository/service methods. Use$transactionfor multi-write invariants. Raw SQL only via taggedprisma.$queryRawtemplates — never string-concatenate SQL. - TypeORM 1.0: inject repositories with
@InjectRepository(User);synchronize: trueis banned outside tests — use generated migrations. Repository access lives in services, not controllers. - Map to response DTOs before returning. Persistence models carry secrets (
passwordHash), relations, and DB-shaped fields. Return a plain response object/DTO; strip sensitive fields at the mapping step, not by hoping serialization hides them.
Testing
- Unit tests build an isolated module with
Test.createTestingModule({...}).compile()and replace every dependency with a mock viauseValue/useClass; assert service logic without touching the DB or HTTP. Resolve withmoduleRef.get(UsersService). - Override in tests, don't re-export from prod:
Test.createTestingModule({...}).overrideProvider(MAILER).useValue(fakeMailer). - e2e tests boot the real app graph, apply the same global pipes/filters as
main.ts, and drive it withsupertest(app.getHttpServer()); assert status codes and response shape, including validation 400s and auth 401/403s. Run e2e with--runInBand. - Mock the ORM at the service edge (a repository/PrismaService mock), or run integration tests against a disposable Postgres (Testcontainers). Do not mock deep Prisma internals.
- Use
@swc/jestfor transform. Keepjest --coveragethresholds meaningful (branches on services), not a vanity 100%. - Always
await app.close()inafterAllto release handles.
Security
- Global input hardening:
ValidationPipewithwhitelist+forbidNonWhitelistedis your primary defense against mass-assignment. Never disable it "temporarily". helmet()and an explicit CORS allowlist (app.enableCors({ origin: [...] })) — neverorigin: truein prod. Set a body size limit.- Rate-limit with
@nestjs/throttler(ThrottlerGuardviaAPP_GUARD); tighten limits on auth endpoints. - Passwords:
argon2.hash(or bcrypt cost ≥ 12). Never log, return, or store plaintext. JWT: short-lived access tokens + rotating refresh tokens, pin the algorithm (HS256/RS256), verifyexp; secret ≥ 32 chars from config. - No SQL string interpolation — rely on the ORM's parameterization; raw queries use tagged templates/parameters only.
- Don't leak internals: the exception filter returns generic messages for 5xx; Pino redacts
authorization,password,token, and cookie headers (redactpaths). - For cookie/session auth add CSRF protection; prefer stateless bearer tokens for APIs. Keep dependencies patched (
pnpm audit/Dependabot).
Do
- Keep controllers to routing + delegation; put all logic and persistence in services.
- Validate every
@Body/@Query/@Paramwith a DTO or parse pipe; map DB models to response DTOs before returning. - Inject via constructor with
private readonly; program to tokens for infra you'll swap or mock. - Register cross-cutting concerns with
APP_GUARD/APP_INTERCEPTOR/APP_FILTER/APP_PIPE. - Fail fast on boot: schema-validate config;
app.enableShutdownHooks(). - Use
nest gschematics, path aliases, and one class per file. - Write a
*.spec.tsnext to each service and an e2e test for each controller's happy path plus a validation/authz failure.
Avoid
- Business logic, DB calls, or
try/catchcontrol flow in controllers → move to a service; let the exception filter format errors. new ValidationPipe()with no options, ortransform/whitelistdisabled → alwayswhitelist + forbidNonWhitelisted + transform.- Returning entities/Prisma models directly → map to a response DTO; strip secrets explicitly.
Scope.REQUESTby default → keep singletons; usenestjs-clsfor request context.forwardRef()to paper over circular deps → extract a shared module.- Reading
process.envoutside the config layer, or an unvalidated config → centralize + schema-validate. synchronize: true(TypeORM) or ad-hoc schema drift → migrations only outside tests.@Res()withoutpassthrough(bypasses interceptors/filters), the deprecatedHttpModulefrom@nestjs/common(use@nestjs/axios),require(), andany.@Global()on every module → reserve for config/Prisma; keep the dependency graph explicit.
When you code
- Make small, single-purpose diffs. Touch one feature module at a time; don't restructure modules and add features in the same change.
- After every change run, in order:
tsc --noEmit(ornest build),eslint . --max-warnings=0, then the relevantjest/e2e suite. Do not report done until all three pass. - New endpoint = DTO(s) + service method + controller wiring +
*.spec.ts+ an e2e assertion. New provider = added to a module'sprovidersand, if shared,exports. - Adding a dependency, a DB migration, or changing the public API of an exported service? State it and ask first — especially anything touching schema, auth, or config validation.
- Mirror the existing validation stack, error shape, and folder conventions already in the repo; do not introduce a second DTO/validation style. When a rule here conflicts with an established repo convention, flag it rather than silently diverging.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on TypeScript 6 · NestJS 11 · Node 24 LTS · Prisma 7.