DEV Community

Cover image for NestJS Best Practices for 2026: A Practical Guide (Updated for NestJS 12)
Sanjay Singh
Sanjay Singh

Posted on Originally published at zyvop.com

NestJS Best Practices for 2026: A Practical Guide (Updated for NestJS 12)

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 or ESLint (sources disagree, 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 upgrade
Enter fullscreen mode Exit fullscreen mode

nest 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.

  • TypeScript jumps to v6: nest upgrade raises typescript to ^6.0.0, and it bumps Jest to v30 and Joi to v18 as well. The upgrade schematic flags module: commonjs combined with legacy module resolution, and a missing rootDir in tsconfig.build.json (error TS5011). Budget time for new compiler errors.

  • Logger output changed: ConsoleLogger now treats extra object arguments as structured params by default. If anything parses your logs, set structuredParams: false to get the old output back.

  • 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
  auth/
    auth.guard.ts
    public.decorator.ts
  common/
    domain-error.ts
    domain-error.filter.ts
  database/
    ...
  users/
    users.module.ts
    users.controller.ts
    users.service.ts
    users.repository.ts
    db-users.repository.ts
    dto/
      create-user.dto.ts
  orders/
    ...
  payments/
    ...
  health/
    ...
Enter fullscreen mode Exit fullscreen mode

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:

Mermaid Diagram

My rules for modules:

  1. Export as little as possible. Export the service other features need. Don't export repositories or internals.

  2. Avoid one giant SharedModule. It slowly becomes a junk drawer that everything imports. Small, named modules age better.

  3. 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,
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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<number>('PORT'));
}
await bootstrap();
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Mermaid Diagram

If 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.

The dotted lines show where errors go. An exception thrown by a guard, a pipe, an interceptor or the handler ends up in the exception filters. That's how the UnauthorizedException from the auth guard later in this post becomes a clean 401 response.

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() {}
Enter fullscreen mode Exit fullscreen mode

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 normally fine.

There is one rule: a singleton must not keep per-request state. If a provider stores "the current user" in a field, two overlapping requests will overwrite each other's value. Keep request data in method arguments, or use AsyncLocalStorage (more on that below).

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 interface User {
  id: string;
  email: string;
  displayName?: string | null;
}

export abstract class UsersRepository {
  abstract findById(id: string): Promise<User | null>;
  abstract findByEmail(email: string): Promise<User | null>;
  abstract insert(data: { email: string; displayName?: string }): Promise<User>;
}
Enter fullscreen mode Exit fullscreen mode

DbUsersRepository is whatever implements that contract with your ORM. Here is the shape to fill in (the bodies are placeholders):

// src/users/db-users.repository.ts
import { Injectable } from '@nestjs/common';
import { type User, UsersRepository } from './users.repository.js';

@Injectable()
export class DbUsersRepository extends UsersRepository {
  // inject your ORM client here (Prisma, Drizzle, TypeORM, ...)

  async findById(id: string): Promise<User | null> {
    throw new Error('Implement with your ORM');
  }
  async findByEmail(email: string): Promise<User | null> {
    throw new Error('Implement with your ORM');
  }
  async insert(data: { email: string; displayName?: string }): Promise<User> {
    throw new Error('Implement with your ORM');
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { DbUsersRepository } from './db-users.repository.js';
import { UsersController } from './users.controller.js';
import { UsersRepository } from './users.repository.js';
import { UsersService } from './users.service.js';

@Module({
  controllers: [UsersController],
  providers: [
    UsersService,
    { provide: UsersRepository, useClass: DbUsersRepository },
  ],
  exports: [UsersService],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Four gotchas from the docs

  1. 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.

  2. Use concrete classes. TypeScript doesn't emit metadata for generics or interfaces, so the pipe can't validate them.

  3. Don't use import type for DTO classes. Type-only imports are erased at runtime, and the pipe needs the real class. This rule is about classes. In the schema style below, the DTO is only a type and the schema is the real value, so import the schema normally and mark the type with the inline type modifier.

  4. 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:

Zod version: These examples use the Zod 4 helpers like z.email() and z.url(). On Zod 3, write z.string().email() and z.string().url() instead.

// src/users/dto/create-user.dto.ts
// The schema-first version. Use this file instead of the class version above.
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.email(),
  displayName: z.string().trim().max(50).optional(),
});

export type CreateUserDto = z.infer<typeof createUserSchema>;
Enter fullscreen mode Exit fullscreen mode

Now register the pipe. This step is not optional. The schema option only attaches the schema to the parameter. If you forget the pipe, the schema does nothing and the request goes through unchecked.

// src/setup-app.ts
import { StandardSchemaValidationPipe, ValidationPipe } from '@nestjs/common';

// Both pipes can be registered together. ValidationPipe checks class-typed
// parameters, and StandardSchemaValidationPipe checks parameters with a schema.
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
  new StandardSchemaValidationPipe(),
);
Enter fullscreen mode Exit fullscreen mode

If a feature uses only schemas, the schema pipe alone is enough. Then use it in the controller:

// src/users/users.controller.ts
import { createUserSchema, type CreateUserDto } from './dto/create-user.dto.js';

@Post()
create(@Body({ schema: createUserSchema }) dto: CreateUserDto) {
  return this.usersService.create(dto);
}
Enter fullscreen mode Exit fullscreen mode

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<typeof listUsersQuerySchema>) {
  return this.usersService.findAll(query);
}
Enter fullscreen mode Exit fullscreen mode

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 and class-based DTOs (the Swagger CLI plugin reads classes) Teams that already like schema-first, shared schemas

Choosing schemas doesn't mean giving up OpenAPI. Standard Schema schemas can feed OpenAPI generation too, so check the Swagger chapter for how to set that up.

The Nest team is clear that this is not a replacement. The docs still suggest class-validator as the default for most projects. According to the validation docs, both pipes can be registered at the same time. ValidationPipe only validates parameters typed with a class, and StandardSchemaValidationPipe only validates parameters that declare a schema. 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);
}
Enter fullscreen mode Exit fullscreen mode

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. Keep the schema in its own file:

// src/config/env.schema.ts
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),
});
Enter fullscreen mode Exit fullscreen mode
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { envSchema } from './config/env.schema.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: envSchema,
    }),
    // ...feature modules
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Then read values through ConfigService, not process.env:

const port = config.getOrThrow<number>('PORT');
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
// in a module, for example AppModule
import { APP_FILTER } from '@nestjs/core';

providers: [{ provide: APP_FILTER, useClass: DomainErrorFilter }],
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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. Fixing that takes two steps. First, tell the HTTP adapter to trust the proxy. Without this, req.ip and req.ips never contain the forwarded address, and no throttler override can use it:

// src/main.ts (Express)
import type { NestExpressApplication } from '@nestjs/platform-express';

const app = await NestFactory.create<NestExpressApplication>(AppModule, { /* ... */ });
app.set('trust proxy', 1); // the number of proxies in front of your app

Use a hop count or a subnet, not true. If your app is ever reachable without going through the proxy, clients can spoof X-Forwarded-For. On Fastify, set the adapter's trustProxy option instead. This goes in main.ts, not setupApp(), because INestApplication has no set() and your tests don't need it.

Second, have the throttler key on the forwarded address. Once trust proxy is set correctly, plain req.ip is often enough. If you need explicit control, extend the guard the way the docs do and register it in place of ThrottlerGuard:

// src/common/throttler-behind-proxy.guard.ts
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    return req.ips?.length ? req.ips[0] : req.ip;
  }
}
// providers: [{ provide: APP_GUARD, useClass: ThrottlerBehindProxyGuard }]

The other trap is that 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 = () => SetMetadata(IS_PUBLIC_KEY, true);
Enter fullscreen mode Exit fullscreen mode
// 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<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Two notes on this guard:

  • It assumes JwtModule is registered with a secret, for example with JwtModule.registerAsync reading JWT_SECRET from ConfigService. Without a secret, verifyAsync has nothing to check tokens against.

  • Global guards run in the order you register them. List ThrottlerGuard before AuthGuard, so a flood of bad requests gets rate-limited before you spend time verifying tokens.

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).

npm i @nestjs/resilience

# only if you also want idempotency keys on POST routes (see the rules below)
npm i @nestjs/idempotency
Enter fullscreen mode Exit fullscreen mode

Here's the idea in small form. Imagine checkout asks a shipping carrier for quotes. Import ResilienceModule once, in the root module (it's global):

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ResilienceModule } from '@nestjs/resilience';

@Module({
  imports: [
    ResilienceModule.forRoot({
      presets: {
        carrier: {
          timeout: '2s',
          retry: { attempts: 2 },
          circuitBreaker: {
            failureRateThreshold: 50,
            minimumCalls: 10,
            openDuration: '30s',
          },
        },
      },
    }),
    // ...feature modules
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode
// src/shipping/shipping.controller.ts
import { CircuitOpenError, Fallback, Resilience, Signal, Timeout } from '@nestjs/resilience';

@Get('quotes')
@Resilience('carrier')
@Timeout('1.5s')
@Fallback('flatRateQuotes', {
  handleIf: (error) => error instanceof CircuitOpenError,
})
getQuotes(@Query('orderId') orderId: string, @Signal() signal: AbortSignal) {
  return this.shippingService.getQuotes(orderId, signal);
}

flatRateQuotes() {
  return this.shippingService.flatRateQuotes();
}
Enter fullscreen mode Exit fullscreen mode

What this does for you:

  • A slow carrier costs the route about 3.2 seconds at most (two 1.5-second attempts plus up to 200 ms of backoff), 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:

Mermaid Diagram

The rules that keep this safe

These come straight from the docs, and they're worth printing out:

  • The decorators only work on entry points. That means controllers, resolvers, message handlers and gateways. On a service method they do nothing, and Nest logs a warning at startup. The next section shows what to use inside services.

  • 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. Also register a shared idempotency store before you go to production. Without one, the records live in each process's memory and are lost on every deploy, and the docs say production refuses to start.

  • 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.

Calls inside services

A background job, or any service that calls another system on its own, isn't an entry point. For those, get a policy object from ResilienceService. resilience.preset('carrier') gives you the same carrier preset, and it shares the breaker the routes use. So a job that finds the carrier failing also protects checkout, and the other way around.

// src/shipping/repricing.service.ts
import { Injectable } from '@nestjs/common';
import { ResilienceService, type ResiliencePolicy } from '@nestjs/resilience';
import type { Order } from '../orders/order.js'; // stand-in: your own order type
import { CarrierClient } from './carrier.client.js'; // stand-in: your own carrier client

@Injectable()
export class RepricingService {
  private readonly policy: ResiliencePolicy;

  constructor(
    resilience: ResilienceService,
    private readonly carrier: CarrierClient,
  ) {
    // Create the policy once, not on every call.
    this.policy = resilience.preset('carrier');
  }

  getQuotes(order: Order) {
    return this.policy.execute(
      ({ signal }) => this.carrier.getQuotes(order, signal),
      { source: 'RepricingService.getQuotes' },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

One detail to know: a policy object has no GET or POST to look at, so the preset's retry always applies. Only wrap calls that are safe to repeat.

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(() => this.payments.ping())
      .withTimeout(1000);
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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([() => this.payments.isHealthy('payments')]);
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { PaymentsModule } from '../payments/payments.module.js'; // must export PaymentsClient
import { HealthController } from './health.controller.js';
import { PaymentsHealthIndicator } from './payments.health.js';

@Module({
  imports: [TerminusModule, PaymentsModule],
  controllers: [HealthController],
  providers: [PaymentsHealthIndicator],
})
export class HealthModule {}
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode

In v12 you can also attach structured data to a message, and it stays in one log entry. This is on by default (see the upgrade notes in section 1 if you need the old output back):

this.logger.log('User signed in', { userId: 1, method: 'oauth' });
Enter fullscreen mode Exit fullscreen mode

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.

Tracing and error monitoring

If you want tracing and error monitoring, Nest 12 promotes @nestjs/observe. Know what you're opting into: the SDK sends telemetry to NestJS Observe, a hosted dashboard at observe.nestjs.com. You sign up, get an app key and secret (keep them in your secret store), and pick a plan. Features are tiered (for example, log forwarding needs Pro or above), so read the pricing page and the terms before you commit.

Two defaults are worth knowing before you turn it on:

  • Log forwarding is off by default.

  • Error source context is on by default. When the SDK captures an error, it attaches a few lines of your application source around each stack frame and sends them to the dashboard. If shipping any source code is not acceptable for your codebase, set sourceContext: false in createObserveModule().

It's optional. nest new --observe and nest upgrade --observe can set it up for you, and OpenTelemetry still works 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', () => {
  let service: UsersService;
  const repo = { findById: vi.fn(), findByEmail: vi.fn(), insert: vi.fn() };

  beforeEach(async () => {
    vi.resetAllMocks();
    const moduleRef = await Test.createTestingModule({
      providers: [UsersService, { provide: UsersRepository, useValue: repo }],
    }).compile();
    service = moduleRef.get(UsersService);
  });

  it('rejects a duplicate email', async () => {
    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();
  });
});
Enter fullscreen mode Exit fullscreen mode

No database, no network. This runs in milliseconds, so you can have hundreds of them.

End-to-end tests: the real app, with the outside world stubbed out

AppModule validates its config at startup (section 7), so the test process needs the variables your schema demands. With Vitest you can set them in the config:

// vitest config
test: {
  env: {
    NODE_ENV: 'test',
    DATABASE_URL: 'postgres://test:test@localhost:5432/test',
    JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
  },
},
Enter fullscreen mode Exit fullscreen mode

The database needs the same care. Replacing UsersRepository with a fake doesn't stop an imported database module from trying to connect. Either override its connection provider, as below, or point DATABASE_URL at a throwaway test database. The second is closer to production, and slower.

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 { DATABASE_CONNECTION } from '../src/database/database.constants.js'; // stand-in: use the token your database module exposes
import { setupApp } from '../src/setup-app.js';
import { UsersRepository } from '../src/users/users.repository.js';

describe('POST /users', () => {
  let app: INestApplication;
  const fakeRepo = {
    findById: async () => null,
    findByEmail: async () => null,
    insert: async (data: object) => ({ id: 'u1', ...data }),
  };

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
      .overrideProvider(UsersRepository)
      .useValue(fakeRepo)
      .overrideProvider(DATABASE_CONNECTION)
      .useValue({})
      .compile();

    app = moduleRef.createNestApplication();
    setupApp(app); // same global setup as production
    await app.init();
  });

  afterAll(() => app.close());

  it('rejects fields we did not ask for', () =>
    request(app.getHttpServer())
      .post('/users')
      .send({ email: 'a@b.co', isAdmin: true })
      .expect(400));
});
Enter fullscreen mode Exit fullscreen mode

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.

  1. Keep request scope rare. We covered this in section 5.

  2. 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.

  3. Cache what's expensive and rarely changes, using Nest's caching module, and always decide how it expires.

  4. 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.

  5. 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, with no per-request state. Abstract classes as tokens. AsyncLocalStorage over request scope.
Validation Global pipe with whitelist, forbidNonWhitelisted, transform. If you use schemas, register StandardSchemaValidationPipe. 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 (with trust proxy behind a load balancer), deny-by-default auth, hashed passwords.
Data Repository, migrations, transactions, unique indexes.
Reliability Timeouts (decorators on entry points, policy objects in services), safe retries, a breaker per dependency, idempotency keys with a shared store for POST.
Operations Health route, JSON logs, shutdown hooks.
Testing Fast unit tests, a few e2e tests that use setupApp() and a stubbed or throwaway database.

Wrapping up

If you only do three things after reading this, do these:

  1. Put your setup in one setupApp() and use it in your tests.

  2. Validate input and config, so bad data fails early and loudly.

  3. Put a timeout on every call to something you don't control. Use the decorators on entry points and a policy object inside services.

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


Published via ZyVOP — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.

Top comments (0)