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

```bash
# 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
```

`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:

```text
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/
    ...
```

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](https://mermaid.ink/img/pako:eNp1kDFrwzAQhf_K9aYW5KHQyUPBjQnpEApN08XKcLLPtqhsCUlpmgb_9xILUlrIpKen7907dMLaNow5tsYe6p58hLdSjgAAhXOVxMK5tW32hiXuIMseYWHHVneVxCTSI9x2xioydxJ3l_SMbwP7UEmcz8ukv8yLbxKUxBVqxWRiX0lM4h-Vor-VyZ3lbJaqklhSJEWBr2dLhQIH9gPpBvMTxp6H8_c03NLeRBTJeSevSRkOZ6a1Y1zSoM0Rc8zIOcNZOIbIg4Ano8ePNdWb-b60YxQgccOdZdg-SxTwapWNVsCKzSdHXZOAwmsyAgKNIQvsdYtiLtno7_Mu9w_uC6dJoOoW1liPOd4ceh0Zpx-LZJcg?type=png)

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.

```typescript
// 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,
    }),
  );
}
```

```typescript
// 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();
```

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.

```typescript
// 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);
  }
}
```

```typescript
// 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:

![Mermaid Diagram](https://mermaid.ink/img/pako:eNp1kUtrAkEQhP9Kp08JzAYCOXkQ4mONECEoCYEdD-Nurw6Znd70jDEP_O9hR9CAeOz-qqoL-hdLrgh7WDvelRsjEZ7m2gMAPBQap77kxvo1CH1sKUSNS8iyPgwKjTNbVY52Rkjj8mAZJDgsNE62RqpwBMMERikxkpTURpYA1yuqWejmqBsl3bjQ-GxbOvnHaZ8XGue8jQQb4ytHcuR54pOzfFNHklP8JMkeuxgKLftA_xvegkYSYdEIt1kf3gqN468uy7KH2rpIEv5XPTMcy14g-UUyuUjeDp1RYUPSGFth7xfjhpruaxXVZusiqsPm1Yg1K0eh09TsY24a676xh5lpW0dZ-A6RGgUDZ_37zJSLNOfsowKNC1ozwctUo4I5rziygkdynxRtaRQ8iDVOQTA-ZIHE1qjSkYX96brc3bdfuN8rXK2H7Fiwh1e7jY2E-z-W-bnx?type=png)

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:

```typescript
@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 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:

```typescript
// 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>;
}
```

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

```typescript
// 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');
  }
}
```

```typescript
// 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 {}
```

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:

```typescript
// 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

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](https://standardschema.dev/) 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.

```typescript
// 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>;
```

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.

```typescript
// 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(),
);
```

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

```typescript
// 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);
}
```

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:

```typescript
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);
}
```

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:

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

```typescript
// 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),
});
```

```typescript
// 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 {}
```

Then read values through `ConfigService`, not `process.env`:

```typescript
const port = config.getOrThrow<number>('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`:

```typescript
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:

```typescript
// 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);
  }
}
```

```typescript
// 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,
    );
  }
}
```

```typescript
// in a module, for example AppModule
import { APP_FILTER } from '@nestjs/core';

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:

```typescript
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. 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:
> 
> ```typescript
> // 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`:
> 
> ```typescript
> // 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:

```typescript
// src/auth/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
```

```typescript
// 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;
  }
}
```

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

```bash
npm i @nestjs/resilience

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

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

```typescript
// 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 {}
```

```typescript
// 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();
}
```

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](https://mermaid.ink/img/pako:eNptzz1Pw0AMBuC_YjyiywBiyoAERVUZEBIVLJTBSZzE4nKO7pxCqfrfUXJC6sBmv3rkjyPW2jCWmIyMH4S6SEOxv94FAID3yw8oiltYeU3c5CzXS_w8ciihJfFTZIhkDKYKvXR9tjNY5IZ8mzW1xhF05PAwRTLRkOmfONtXwhi1YqjJe0hTXTM36R-dB5_Z-aKEDgeOA0mD5RGt52F-s-GWJm_ocvJGUajynGbTarA1DeIPWGJB4-i5SIdkPDi49xI-n6jeLv1agznY4ZY7ZXh93KGDF63U1MGG_Z5NanJwF4W8g0QhFYmjtOiWJVv5mW-5uhm_8XRyWHUr9RqxxIuvXozx9AvOFoaw?type=png)

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

```typescript
// 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' },
    );
  }
}
```

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.

```typescript
// 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);
  }
}
```

```typescript
// 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')]);
  }
}
```

```typescript
// 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 {}
```

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:

```typescript
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 is on by default (see the upgrade notes in section 1 if you need the old output back):

```typescript
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.

### 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

```typescript
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();
  });
});
```

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:

```typescript
// 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',
  },
},
```

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.

```typescript
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));
});
```

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

-   [NestJS v12 is Now Available (Trilon)](https://trilon.io/blog/nestjs-12-is-now-available)
    
-   [NestJS v12 is Coming: What's New (Trilon)](https://trilon.io/blog/nestjs-12-is-coming)
    
-   [NestJS v12.0.0 release notes (GitHub)](https://github.com/nestjs/nest/releases/tag/v12.0.0)
    
-   [@nestjs/schematics 12.0.0 release notes (GitHub)](https://github.com/nestjs/schematics/releases/tag/12.0.0)
    
-   [Migration guide (NestJS docs)](https://docs.nestjs.com/migration-guide)
    
-   [Validation (NestJS docs)](https://docs.nestjs.com/application/validation)
    
-   [Injection scopes (NestJS docs)](https://docs.nestjs.com/fundamentals/injection-scopes)
    
-   [Resilience (NestJS docs)](https://docs.nestjs.com/reliability/resilience)
    
-   [Idempotency keys (NestJS docs)](https://docs.nestjs.com/reliability/idempotency)
    
-   [Rate limiting (NestJS docs)](https://docs.nestjs.com/security/rate-limiting)
    
-   [NestJS Observe overview (NestJS docs)](https://docs.nestjs.com/observability/overview)
    
-   [NestJS Observe SDK (NestJS docs)](https://docs.nestjs.com/observability/sdk)

---

*Published via [ZyVOP](https://zyvop.com/nestjs-best-practices-for-2026-a-practical-guide-updated-for-nestjs-12-x3fa5?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
