{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"nestjs-best-practices-for-2026-a-practical-guide-updated-for-nestjs-12-x3fa5","url":"https://zyvop.com/nestjs-best-practices-for-2026-a-practical-guide-updated-for-nestjs-12-x3fa5","title":"NestJS Best Practices for 2026: A Practical Guide (Updated for NestJS 12)","subtitle":"A practical, up-to-date guide to structuring, validating, securing, testing and running NestJS apps, updated for NestJS 12.","tldr":"NestJS 12 changed a lot of defaults. Here are the practices I'd apply to every new project today: feature modules, validation, config, errors, security, resilience, health checks and testing, with code based on the official docs.","keywords":["backend","TypeScript","node.js","Best Practices","NestJS"],"entities":["Sanju Singh","backend","TypeScript","node.js","Best Practices","NestJS","ZyVOP"],"keyTakeaways":["NestJS 12 came out on August 28, 2026, and it changes more than a normal major release.","The core packages are now ESM.","New projects get a different test and lint setup."],"headings":["1. Set up the project on purpose","2. Organize by feature, not by file type","3. Put app-wide setup in one place","4. Keep controllers thin","Know where each piece belongs","A route trap worth knowing","5. Let dependency injection stay boring","Avoid request scope unless you really need it","Inject by abstraction, not by concrete class","6. Validate every request at the edge","Four gotchas from the docs","The new option in v12: schema validation","So which one should you pick?","Don't return your database objects","7. Validate your config, too","8. Make errors useful","For bigger apps: keep HTTP out of your business code","9. Security basics you shouldn't skip","10. Data access: a few habits that pay off","11. Be kind to the things you call","The rules that keep this safe","More official building blocks worth knowing","12. Health checks, logs and a clean shutdown","Health checks"],"outboundLinks":["https://standardschema.dev/","https://trilon.io/blog/nestjs-12-is-now-available","https://trilon.io/blog/nestjs-12-is-coming","https://docs.nestjs.com/migration-guide","https://docs.nestjs.com/application/validation","https://docs.nestjs.com/fundamentals/injection-scopes","https://docs.nestjs.com/reliability/resilience","https://docs.nestjs.com/security/rate-limiting"],"contentText":"NestJS 12 came out on August 28, 2026, and it changes more than a normal major release. The core packages are now ESM. New projects get a different test and lint setup. You can validate requests with Zod. And the docs have a whole new section about reliability. That means a lot of older \"best practices\" posts are now only half right. So this is my list of what I'd do on a new NestJS project today. Each tip comes with the reason behind it and code you can copy. When something is my opinion and not an official rule, I'll tell you. A quick note on the code: The examples use ESM style, which is what you get when you pick ESM in nest new. That means imports like ./app.module.js and a top-level await. If your project is CommonJS, drop the .js extensions and call bootstrap() without await. Versions: To run a Nest 12 app you need Node.js 20.19+ or 22.12+. The CLI generators (nest new, nest generate, nest upgrade) need a newer Node: 22.22.3+, 24.15+ or 26+. The easy answer is to use the latest active LTS. 1. Set up the project on purpose When you run nest new, the CLI now asks if you want a CommonJS or an ESM project. The choice changes your defaults: ESM project CommonJS project Test runner Vitest Jest Linter oxlint oxlint (see the note below) Compiler tsc tsc Bundler for monorepos Rspack Rspack Heads up: The official sources don't fully agree on the linter. The migration guide says every generated project uses oxlint. The launch post says the CommonJS template keeps ESLint. Open your generated package.json and see what you actually got. Here's how I'd decide: New service, nothing legacy: pick ESM. You get the modern defaults from day one. Existing app: upgrade the packages and stay on CommonJS until you have a real reason to switch. Staying on CommonJS is fine because Nest's ESM packages can be loaded from CommonJS through require(esm). The upgrade command even leaves your module format alone. # upgrade the CLI first, globally and locally npm i -g @nestjs/cli@latest @nestjs/schematics@latest npm i -D @nestjs/cli@latest @nestjs/schematics@latest # see what would change without touching files nest upgrade --dry-run # then do it for real nest upgradenest upgrade moves every @nestjs/* package to v12 at once. That part matters. Keep all Nest packages on the same major version, always. Mixed majors are a classic source of strange errors. A few things can bite you during the upgrade: Jest users: Jest can load the ESM-only v12 packages only on Node.js 24.9 or newer. Older versions fail with ERR_REQUIRE_ASYNC_MODULE. Use Node 24.9+ or move to Vitest. AWS Lambda: The Node 20, 22 and 24 runtimes turn require(esm) off by default. A CommonJS Nest 12 app needs NODE_OPTIONS=--experimental-require-module. Lifecycle hook order changed: Hooks like onModuleInit now run by component hierarchy level. If your code depends on the order between providers, test it. @Optional() is no longer inherited: A subclass has to declare it again in its own constructor, or Nest throws UnknownDependenciesException. Removed or replaced: the old Terminus health indicator API, subscriptions-transport-ws in GraphQL (use graphql-ws), and the old nats package (now @nats-io/transport-node). Webpack is deprecated in CLI workflows. Rspack takes over for monorepos. tsc is still the default compiler. And one rule I like: after upgrading, fix every deprecation warning in your console before you ship. They are cheap to fix now and expensive later. 2. Organize by feature, not by file type The folder layout I'd start with: src/ main.ts setup-app.ts app.module.ts config/ env.schema.ts users/ users.module.ts users.controller.ts users.service.ts users.repository.ts dto/ create-user.dto.ts orders/ ... health/ ...Everything about \"users\" lives in users/. You don't hunt through controllers/, services/ and entities/ folders to change one feature. Each feature is a module, and modules only talk through what they export: flowchart TD App[\"AppModule\"] --&gt; Config[\"ConfigModule (global)\"] App --&gt; Users[\"UsersModule\"] App --&gt; Orders[\"OrdersModule\"] App --&gt; Health[\"HealthModule\"] Orders --&gt; Users Users --&gt; Db[\"DatabaseModule\"] Orders --&gt; DbMy rules for modules: Export as little as possible. Export the service other features need. Don't export repositories or internals. Avoid one giant SharedModule. It slowly becomes a junk drawer that everything imports. Small, named modules age better. Treat forwardRef() as a smell. If two modules need each other, the usual fix is a third module that holds what they share. 3. Put app-wide setup in one place Here's a habit that saves a lot of \"works on my machine\" bugs. Put your global setup in one function, and call it from both main.ts and your end-to-end tests. // src/setup-app.ts import { ValidationPipe, type INestApplication } from '@nestjs/common'; import helmet from 'helmet'; /** App-wide setup. main.ts and the e2e tests both call this. */ export function setupApp(app: INestApplication) { app.use(helmet()); app.enableCors({ origin: ['https://app.example.com'] }); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); }// src/main.ts import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; import { AppModule } from './app.module.js'; import { setupApp } from './setup-app.js'; async function bootstrap() { const app = await NestFactory.create(AppModule, { routeConflictPolicy: { duplicate: 'error', shadow: 'warn' }, routeResolutionStrategy: 'specificity', }); setupApp(app); app.enableShutdownHooks(); const config = app.get(ConfigService); await app.listen(config.getOrThrow&lt;number&gt;('PORT')); } await bootstrap();I'll explain each line in the sections below. The reason for the shared function is simple: if your tests build the app without your global pipes, they test a different app than the one you ship. Using Fastify? Use @fastify/helmet instead of helmet. 4. Keep controllers thin A controller should do three things: read the input, call a service, return the result. No business rules. No database calls. // src/users/users.controller.ts import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { Public } from '../auth/public.decorator.js'; // defined in the security section import { CreateUserDto } from './dto/create-user.dto.js'; import { UsersService } from './users.service.js'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Public() @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string) { return this.usersService.findOne(id); } }// src/users/users.service.ts import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto.js'; import { UsersRepository } from './users.repository.js'; @Injectable() export class UsersService { constructor(private readonly users: UsersRepository) {} async create(dto: CreateUserDto) { const existing = await this.users.findByEmail(dto.email); if (existing) { throw new ConflictException('Email is already in use', { errorCode: 'EMAIL_TAKEN', }); } return this.users.insert(dto); } async findOne(id: string) { const user = await this.users.findById(id); if (!user) { throw new NotFoundException('User not found', { errorCode: 'USER_NOT_FOUND' }); } return user; } }Careful with \"check, then insert\": Two requests can pass the findByEmail check at the same time. Keep a unique index on the email column and treat that database error as the real source of truth. The check in the service is just for a friendly message. Know where each piece belongs Nest gives you five tools that wrap a request. People mix them up all the time. This is how I think about them: Tool Its job Typical use Middleware Low-level request work Request IDs, raw body handling Guard \"Is this caller allowed?\" Authentication, roles Pipe \"Is this input valid? Convert it.\" Validation, ParseUUIDPipe Interceptor Wrap the handler Timing, response shaping, resilience Exception filter Turn errors into responses A consistent error format And this is the order they run in: flowchart LR A[\"Incoming request\"] --&gt; B[\"Middleware\"] B --&gt; C[\"Guards\"] C --&gt; D[\"Interceptors (before)\"] D --&gt; E[\"Pipes\"] E --&gt; F[\"Route handler\"] F --&gt; G[\"Interceptors (after)\"] G --&gt; H[\"Response\"] F -. \"error\" .-&gt; X[\"Exception filters\"] X --&gt; HIf you remember one thing from this picture: guards run before pipes. So a user who isn't allowed in never gets as far as validation. A route trap worth knowing Nest registers routes in the order you declare them. On Express, this can quietly break a route: @Get(':id') // declared first, so it can swallow /users/me findOne(@Param('id') id: string) {} @Get('me') me() {}Version 12 adds two opt-in options to catch this, and I turned both on in main.ts above. routeConflictPolicy can warn or throw on shadowed and duplicate routes. routeResolutionStrategy: 'specificity' picks the most specific route. Both default to the old behavior, so nothing changes until you set them. 5. Let dependency injection stay boring Nest providers are singletons by default, and that's what you want. This surprises people coming from other languages. Node.js doesn't handle each request in its own thread, so sharing one instance across requests is safe. Avoid request scope unless you really need it You can make a provider request-scoped, so it gets a fresh instance per request. It's tempting. Here's why I avoid it. Request scope bubbles up. If your CatsService is request-scoped, then the CatsController that uses it becomes request-scoped too. Now Nest creates and throws away that whole chain on every request. The docs say a well-designed app shouldn't pay more than about 5% latency for this, but \"well-designed\" is doing a lot of work in that sentence. Most of the time you only want to read one value that belongs to the current request, like the user, the tenant or the locale. For that, use AsyncLocalStorage. Your providers stay singletons, and the value is still available anywhere downstream. It works the same in HTTP handlers, message handlers and queue jobs. Multi-tenant app? Nest has \"durable providers\" for exactly this case. They let you share one DI sub-tree per tenant instead of rebuilding it per request. The docs warn it's not ideal with a very large number of tenants. Inject by abstraction, not by concrete class TypeScript interfaces disappear at runtime, so they can't be injection tokens. An abstract class works well instead, because it exists at runtime and still describes the contract: // src/users/users.repository.ts export abstract class UsersRepository { abstract findById(id: string): Promise&lt;User | null&gt;; abstract findByEmail(email: string): Promise&lt;User | null&gt;; abstract insert(data: { email: string; displayName?: string }): Promise&lt;User&gt;; }// src/users/users.module.ts @Module({ controllers: [UsersController], providers: [ UsersService, { provide: UsersRepository, useClass: DbUsersRepository }, ], exports: [UsersService], }) export class UsersModule {}Your service doesn't know or care which database sits behind UsersRepository. And in tests you swap it for a fake in one line. We'll use that in the testing section. 6. Validate every request at the edge Rule: never trust anything that comes in over the network. Bind the validation pipe globally (we did that in setup-app.ts) so no endpoint is left unprotected by accident. The three options in that pipe do real work: whitelist: true strips any property that has no validation decorator. forbidNonWhitelisted: true goes further and rejects the request instead of silently stripping. transform: true turns plain payloads into DTO class instances and converts path and query values to the types you declared. A DTO looks like this: // src/users/dto/create-user.dto.ts import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateUserDto { @IsEmail() email: string; @IsOptional() @IsString() @MaxLength(50) displayName?: string; }Four gotchas from the docs Every property needs at least one decorator. With whitelist: true, a property with no decorator gets stripped. If your field \"disappears,\" this is usually why. Use concrete classes. TypeScript doesn't emit metadata for generics or interfaces, so the pipe can't validate them. Don't use import type for DTOs. Type-only imports are erased at runtime, and the pipe needs the real class. Arrays aren't validated by default. @Body() dtos: CreateUserDto[] skips the elements. Wrap the array in a class, or use ParseArrayPipe({ items: CreateUserDto }). The new option in v12: schema validation Nest 12 adds a second way to validate, built on the Standard Schema spec. That means Zod, Valibot, ArkType and others work out of the box. You pass the schema straight into the parameter decorator: // src/users/dto/create-user.dto.ts import { z } from 'zod'; export const createUserSchema = z.object({ email: z.email(), displayName: z.string().trim().max(50).optional(), }); export type CreateUserDto = z.infer&lt;typeof createUserSchema&gt;;// main.ts or setup-app.ts import { StandardSchemaValidationPipe } from '@nestjs/common'; app.useGlobalPipes(new StandardSchemaValidationPipe());@Post() create(@Body({ schema: createUserSchema }) dto: CreateUserDto) { return this.usersService.create(dto); }The type comes from the schema, so you write the rules once and never keep a class and a type in sync. Coercion also works well for query strings: export const listUsersQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().trim().optional(), }); @Get() findAll(@Query({ schema: listUsersQuerySchema }) query: z.infer&lt;typeof listUsersQuerySchema&gt;) { return this.usersService.findAll(query); }Notice the max(100) on limit. Always cap page sizes. An endpoint that lets a client ask for a million rows will eventually get that request. One detail to remember: with Zod, z.object() strips unknown keys (like whitelist), and z.strictObject() rejects them (like forbidNonWhitelisted). So which one should you pick? ValidationPipe + class-validator StandardSchemaValidationPipe Where the rules live Decorators on a DTO class A schema object Where the type comes from The class itself Inferred from the schema Making variants (create vs update) PartialType, PickType, OmitType .partial(), .pick(), .omit() Fits best Existing code, class-based DTOs, the Swagger CLI plugin Teams that already like schema-first, shared schemas The Nest team is clear that this is not a replacement. The docs still suggest class-validator as the default for most projects. Both pipes can run side by side, so you can adopt schemas one feature at a time. My opinion: pick one style per feature so your team doesn't have to switch mental models in the middle of a file. Don't return your database objects Whatever you validate on the way in, shape on the way out. Returning raw entities leaks columns like password hashes and internal flags. Use ClassSerializerInterceptor with class-transformer, or the new StandardSchemaSerializerInterceptor if you're going schema-first: @UseInterceptors(StandardSchemaSerializerInterceptor) @SerializeOptions({ schema: userResponseSchema }) @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string) { return this.usersService.findOne(id); }7. Validate your config, too A missing environment variable should crash your app at startup, not at 3 a.m. when the first request needs it. @nestjs/config in v12 accepts any Standard Schema object for validationSchema, so Zod works directly: // src/app.module.ts import { ConfigModule } from '@nestjs/config'; import { z } from 'zod'; export const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().default(3000), DATABASE_URL: z.url(), JWT_SECRET: z.string().min(32), }); @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: envSchema, }), // ...feature modules ], }) export class AppModule {}Then read values through ConfigService, not process.env: const port = config.getOrThrow&lt;number&gt;('PORT');Three habits I'd keep: One place reads the environment. Everything else asks ConfigService. Fail early and loudly. getOrThrow over get. Never commit secrets. Real values come from your platform's secret store, not from a .env file in git. Still using Joi? It keeps working, but you need Joi v18 or newer, and Joi-specific settings move under validationOptions.libraryOptions. 8. Make errors useful Clients need to react to errors without parsing English sentences. Nest 12 helps with a new errorCode option on every HttpException: throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD', });The code is added to the response body. Your frontend can now do if (error.errorCode === 'WEAK_PASSWORD') and stop matching message strings. Message text can change. Codes shouldn't. For bigger apps: keep HTTP out of your business code If you want services that don't know about HTTP at all, throw your own domain errors and map them in one place: // src/common/domain-error.ts export class DomainError extends Error { constructor( message: string, readonly code: string, readonly httpStatus: number, ) { super(message); } } export class EmailTakenError extends DomainError { constructor() { super('Email is already in use', 'EMAIL_TAKEN', 409); } }// src/common/domain-error.filter.ts import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; import { DomainError } from './domain-error.js'; @Catch(DomainError) export class DomainErrorFilter implements ExceptionFilter { constructor(private readonly httpAdapterHost: HttpAdapterHost) {} catch(error: DomainError, host: ArgumentsHost) { const { httpAdapter } = this.httpAdapterHost; const response = host.switchToHttp().getResponse(); httpAdapter.reply( response, { statusCode: error.httpStatus, error: error.code, message: error.message }, error.httpStatus, ); } }// in a module providers: [{ provide: APP_FILTER, useClass: DomainErrorFilter }],Using HttpAdapterHost instead of response.status().json() keeps the filter working on both Express and Fastify. My rule of thumb: small app, throw Nest's built-in exceptions with an errorCode. Growing app with lots of business rules, use domain errors and one filter. 9. Security basics you shouldn't skip None of this is exciting. All of it matters. Security headers and CORS. We set both in setup-app.ts. Always list your allowed origins. Don't leave CORS open \"just for now.\" Rate limiting. @nestjs/throttler is the official answer. In recent versions, ttl is in milliseconds: import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }])], providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }], }) export class AppModule {}Use @Throttle() to set tighter limits on sensitive routes like login, and @SkipThrottle() for things like health checks. Two traps: Behind a load balancer, every request can look like it comes from the proxy's IP unless you extend ThrottlerGuard to read the real client IP (the docs show a ThrottlerBehindProxyGuard for this). And the default store is in memory, so with several instances each one counts on its own. For a shared limit, plug in a shared store. Deny by default. Instead of remembering to protect every route, protect all of them and mark the open ones. This is the pattern from the official auth docs: // src/auth/public.decorator.ts import { SetMetadata } from '@nestjs/common'; export const IS_PUBLIC_KEY = 'isPublic'; export const Public = () =&gt; SetMetadata(IS_PUBLIC_KEY, true);// src/auth/auth.guard.ts import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { IS_PUBLIC_KEY } from './public.decorator.js'; @Injectable() export class AuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly jwt: JwtService, ) {} async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; { const isPublic = this.reflector.getAllAndOverride&lt;boolean&gt;(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) return true; const request = context.switchToHttp().getRequest(); const [type, token] = request.headers.authorization?.split(' ') ?? []; if (type !== 'Bearer' || !token) throw new UnauthorizedException(); try { request.user = await this.jwt.verifyAsync(token); } catch { throw new UnauthorizedException(); } return true; } }Register it with { provide: APP_GUARD, useClass: AuthGuard }. Now a new endpoint is private until someone deliberately writes @Public(). That's the safe direction to fail in. A few more: Hash passwords, don't encrypt them. Use a slow, salted hash like argon2 or bcrypt. The Nest docs have a chapter on this. Authorization is not authentication. A valid token says who you are. It doesn't say you can touch this record. Check ownership in the service. Turn on CSRF protection if you authenticate with cookies or sessions. Token-in-header APIs don't need it. Keep secrets out of logs. Log IDs, not tokens or passwords. 10. Data access: a few habits that pay off Nest doesn't force an ORM on you. The docs now have chapters for TypeORM, Prisma, Drizzle, MikroORM, Sequelize and MongoDB. Pick the one your team knows. These habits apply to all of them: Hide the database behind a repository (like UsersRepository earlier). Services talk to the contract, not the library. Use migrations. Never let the ORM auto-sync your schema in production. If you use TypeORM, make sure synchronize is off there. Wrap multi-step writes in a transaction. If step two fails, step one should roll back. Let the database enforce the rules. Unique indexes, foreign keys and not-null constraints are your last line of defense, and they never have race conditions. Paginate everything that can grow, and cap the page size. Select only the columns you need. It's less data over the wire and fewer accidental leaks. Watch for N+1 queries. A loop that runs one query per item looks fine on 10 rows and falls over on 10,000. 11. Be kind to the things you call Every app that calls another system will eventually meet one that is slow or down. Without a plan, your request handlers pile up waiting, and retries from every layer make the struggling service even worse. The Nest docs now have a Reliability section, and its first chapter covers @nestjs/resilience. It gives you timeouts, retries, circuit breakers, bulkheads and fallbacks as decorators on your entry points (controllers, resolvers, message handlers and gateways). Here's the idea in small form. Imagine checkout asks a shipping carrier for quotes: // app.module.ts ResilienceModule.forRoot({ presets: { carrier: { timeout: '2s', retry: { attempts: 2 }, circuitBreaker: { failureRateThreshold: 50, minimumCalls: 10, openDuration: '30s', }, }, }, }),// shipping.controller.ts import { CircuitOpenError, Fallback, Resilience, Signal, Timeout } from '@nestjs/resilience'; @Get('quotes') @Resilience('carrier') @Timeout('1.5s') @Fallback('flatRateQuotes', { handleIf: (error) =&gt; error instanceof CircuitOpenError, }) getQuotes(@Query('orderId') orderId: string, @Signal() signal: AbortSignal) { return this.shippingService.getQuotes(orderId, signal); } flatRateQuotes() { return this.shippingService.flatRateQuotes(); }What this does for you: A slow carrier costs the route about three seconds at most, then it answers 504. Once the carrier looks down, the breaker opens and calls fail fast instead of waiting. While the breaker is open, customers see a flat shipping rate instead of an error. The breaker moves between three states: stateDiagram-v2 [*] --&gt; Closed Closed --&gt; Open: failure rate too high Open --&gt; HalfOpen: after openDuration HalfOpen --&gt; Closed: probe call succeeds HalfOpen --&gt; Open: probe call failsThe rules that keep this safe These come straight from the docs, and they're worth printing out: Pass the signal to every I/O call. When a timeout fires, the signal aborts. If your code ignores it, the work keeps running in the background. Retries only apply to safe requests by default. On HTTP that's GET, HEAD and OPTIONS. A POST retry is skipped unless you say @Retry({ idempotent: true }), which means \"running this twice is harmless.\" Protect client retries with idempotency keys. @Idempotent() from @nestjs/idempotency returns the stored response when a client repeats a request with the same Idempotency-Key. Register IdempotencyModule before ResilienceModule, because the first global interceptor runs outermost. Retry at one layer only. A route that retries 3 times, calling a service that retries 3 times, calling an SDK that retries 3 times, can send 27 requests to something that's already struggling. State lives in each process. With several instances, every one has its own breakers and bulkheads. Every breaker needs a timeout. A breaker only learns from calls that finish. A call that hangs forever is never counted. More official building blocks worth knowing Need Where to look Write to your database and publish a message without losing either Transactional outbox chapter Run a scheduled job on only one instance Distributed locks (@nestjs/locks) Send transactional email Mail (@nestjs/mail) Store files on disk or S3-compatible storage File storage (@nestjs/storage) I haven't covered these in depth here. Check each chapter before you commit to one, since several are new. 12. Health checks, logs and a clean shutdown Health checks Add a health endpoint with @nestjs/terminus so your platform knows when to restart or stop sending traffic. In v12, custom indicators use HealthIndicatorService. The old approach of throwing HealthCheckError was removed. // src/health/payments.health.ts import { Injectable } from '@nestjs/common'; import { HealthIndicatorService } from '@nestjs/terminus'; import { PaymentsClient } from '../payments/payments.client.js'; @Injectable() export class PaymentsHealthIndicator { constructor( private readonly healthIndicatorService: HealthIndicatorService, private readonly payments: PaymentsClient, ) {} isHealthy(key: string) { return this.healthIndicatorService .check(key) .attempt(() =&gt; this.payments.ping()) .withTimeout(1000); } }// src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; import { SkipThrottle } from '@nestjs/throttler'; import { Public } from '../auth/public.decorator.js'; import { PaymentsHealthIndicator } from './payments.health.js'; @Controller('health') export class HealthController { constructor( private readonly health: HealthCheckService, private readonly payments: PaymentsHealthIndicator, ) {} @Public() @SkipThrottle() @Get() @HealthCheck() check() { return this.health.check([() =&gt; this.payments.isHealthy('payments')]); } }Notice that the health route is @Public() and skips rate limiting. Your load balancer needs to hit it constantly without a token. My opinion: split it into two routes. A liveness route that only says \"the process is up,\" and a readiness route that also checks the database and other dependencies. That way a slow third party doesn't make your platform kill a perfectly healthy process. Logs Plain text logs are hard to search. Switch the built-in logger to JSON: import { ConsoleLogger } from '@nestjs/common'; const app = await NestFactory.create(AppModule, { logger: new ConsoleLogger({ json: true }), });In v12 you can also attach structured data to a message, and it stays in one log entry: this.logger.log('User signed in', { userId: 1, method: 'oauth' });In JSON mode the extra values sit under a params key. Turn on flattenParams if your log tool prefers them at the top level. Whatever you log, add a request ID so you can follow one request across many lines. AsyncLocalStorage is a good home for it. If you want tracing and error monitoring, Nest 12 ships an official SDK, @nestjs/observe, that hooks into the Nest lifecycle through the instrument option. It reports in terms of your controllers, providers and queue consumers instead of raw HTTP calls. It's optional, and you can keep using OpenTelemetry if that's already your setup. Shutdown Call app.enableShutdownHooks() (we did, in main.ts) so OnModuleDestroy and OnApplicationShutdown run when your platform sends SIGTERM. Without it, connections and queues may not close cleanly during a deploy. New in v12: the Express adapter now drains in-flight requests before the process exits. 13. Test the way Nest wants you to Nest's DI makes testing easy, as long as you actually use it. I split tests into two kinds. Unit tests: the service and a fake import { ConflictException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UsersRepository } from './users.repository.js'; import { UsersService } from './users.service.js'; describe('UsersService', () =&gt; { let service: UsersService; const repo = { findById: vi.fn(), findByEmail: vi.fn(), insert: vi.fn() }; beforeEach(async () =&gt; { vi.resetAllMocks(); const moduleRef = await Test.createTestingModule({ providers: [UsersService, { provide: UsersRepository, useValue: repo }], }).compile(); service = moduleRef.get(UsersService); }); it('rejects a duplicate email', async () =&gt; { repo.findByEmail.mockResolvedValue({ id: '1', email: 'a@b.co' }); await expect(service.create({ email: 'a@b.co' })).rejects.toBeInstanceOf( ConflictException, ); expect(repo.insert).not.toHaveBeenCalled(); }); });No database, no network. This runs in milliseconds, so you can have hundreds of them. End-to-end tests: the real app, minus the outside world import type { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { afterAll, beforeAll, describe, it } from 'vitest'; import { AppModule } from '../src/app.module.js'; import { setupApp } from '../src/setup-app.js'; import { UsersRepository } from '../src/users/users.repository.js'; describe('POST /users', () =&gt; { let app: INestApplication; const fakeRepo = { findById: async () =&gt; null, findByEmail: async () =&gt; null, insert: async (data: object) =&gt; ({ id: 'u1', ...data }), }; beforeAll(async () =&gt; { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(UsersRepository) .useValue(fakeRepo) .compile(); app = moduleRef.createNestApplication(); setupApp(app); // same global setup as production await app.init(); }); afterAll(() =&gt; app.close()); it('rejects fields we did not ask for', () =&gt; request(app.getHttpServer()) .post('/users') .send({ email: 'a@b.co', isAdmin: true }) .expect(400)); });This test only means something because of setupApp(app). Without it, the unknown isAdmin field would sail through. A few notes: @nestjs/testing is test-runner agnostic. Vitest, Jest, whatever you like. If you set up Vitest by hand, remember that Nest's DI needs decorator metadata, and not every transformer emits it. Starting from a generated project saves you this headache. Test the unhappy paths: invalid input, missing auth, duplicate data, a dependency that times out. The happy path usually works already. 14. Performance without drama I'll keep this short, because most Nest performance problems aren't framework problems. Keep request scope rare. We covered this in section 5. Don't block the event loop. Heavy CPU work (image processing, big reports, password hashing in loops) should move to a queue or a worker. The docs have a Queues chapter. Cache what's expensive and rarely changes, using Nest's caching module, and always decide how it expires. Measure before switching to Fastify. Nest has an official Fastify adapter and a performance chapter. It can help, but profile first so you're fixing the real bottleneck. Build faster in development with the SWC recipe if your compile times are slowing you down. The checklist Copy this into your team's wiki: Area Do this Setup Pick CJS or ESM on purpose. Keep all @nestjs/* on one major version. Structure Feature modules. Small exports. No giant SharedModule. App setup One setupApp() used by main.ts and tests. Controllers Thin. Input in, service call, result out. DI Singletons by default. Abstract classes as tokens. AsyncLocalStorage over request scope. Validation Global pipe with whitelist, forbidNonWhitelisted, transform. Cap page sizes. Config Validated at startup. Read through ConfigService. Errors errorCode on exceptions, or domain errors with one filter. Security Helmet, explicit CORS, rate limiting, deny-by-default auth, hashed passwords. Data Repository, migrations, transactions, unique indexes. Reliability Timeouts, safe retries, a breaker per dependency, idempotency for POST. Operations Health route, JSON logs, shutdown hooks. Testing Fast unit tests, a few e2e tests that use setupApp(). Wrapping up If you only do three things after reading this, do these: Put your setup in one setupApp() and use it in your tests. Validate input and config, so bad data fails early and loudly. Put a timeout on every call to something you don't control. NestJS 12 is a good moment to clean house. The framework is moving to ESM, the tooling is getting lighter, and the docs now cover production concerns that used to need extra research. What would you add to this list? Let me know in the comments. Sources and further reading NestJS v12 is Now Available (Trilon) NestJS v12 is Coming: What's New (Trilon) Migration guide (NestJS docs) Validation (NestJS docs) Injection scopes (NestJS docs) Resilience (NestJS docs) Rate limiting (NestJS docs)","contentHash":"sha256:5cd1eae2634f5a178840a19e2bc6b02e0dac691c9d7ca089c06fdf55f412167a","authorName":"Sanju Singh","authorUrl":"https://zyvop.com/author/sanjay687","authorSameAs":[],"category":null,"tags":["backend","TypeScript","node.js","Best Practices","NestJS"],"audience":"Software engineers and developers building applications with backend","tone":"Instructional, practical, code-first","readingTimeMinutes":23,"wordCount":5001,"faqs":null,"primaryTopic":"backend","publishedAt":"2026-10-08T14:44:06.766Z","updatedAt":"2026-10-09T04:56:37.376Z","canonicalUrl":"https://zyvop.com/nestjs-best-practices-for-2026-a-practical-guide-updated-for-nestjs-12-x3fa5"}