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.jsand a top-levelawait. If your project is CommonJS, drop the.jsextensions and callbootstrap()withoutawait.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.jsonand 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
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 needsNODE_OPTIONS=--experimental-require-module.TypeScript jumps to v6:
nest upgraderaisestypescriptto^6.0.0, and it bumps Jest to v30 and Joi to v18 as well. The upgrade schematic flagsmodule: commonjscombined with legacy module resolution, and a missingrootDirintsconfig.build.json(error TS5011). Budget time for new compiler errors.Logger output changed:
ConsoleLoggernow treats extra object arguments as structured params by default. If anything parses your logs, setstructuredParams: falseto get the old output back.Lifecycle hook order changed: Hooks like
onModuleInitnow 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 throwsUnknownDependenciesException.Removed or replaced: the old Terminus health indicator API,
subscriptions-transport-wsin GraphQL (usegraphql-ws), and the oldnatspackage (now@nats-io/transport-node).Webpack is deprecated in CLI workflows. Rspack takes over for monorepos.
tscis 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/
...
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:
My rules for modules:
Export as little as possible. Export the service other features need. Don't export repositories or internals.
Avoid one giant
SharedModule. It slowly becomes a junk drawer that everything imports. Small, named modules age better.Treat
forwardRef()as a smell. If two modules need each other, the usual fix is a third module that holds what they share.
3. Put app-wide setup in one place
Here's a habit that saves a lot of "works on my machine" bugs. Put your global setup in one function, and call it from both main.ts and your end-to-end tests.
// src/setup-app.ts
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import helmet from 'helmet';
/** App-wide setup. main.ts and the e2e tests both call this. */
export function setupApp(app: INestApplication) {
app.use(helmet());
app.enableCors({ origin: ['https://app.example.com'] });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
}
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module.js';
import { setupApp } from './setup-app.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
routeResolutionStrategy: 'specificity',
});
setupApp(app);
app.enableShutdownHooks();
const config = app.get(ConfigService);
await app.listen(config.getOrThrow<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/helmetinstead ofhelmet.
4. Keep controllers thin
A controller should do three things: read the input, call a service, return the result. No business rules. No database calls.
// src/users/users.controller.ts
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { Public } from '../auth/public.decorator.js'; // defined in the security section
import { CreateUserDto } from './dto/create-user.dto.js';
import { UsersService } from './users.service.js';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Public()
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findOne(id);
}
}
// src/users/users.service.ts
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto.js';
import { UsersRepository } from './users.repository.js';
@Injectable()
export class UsersService {
constructor(private readonly users: UsersRepository) {}
async create(dto: CreateUserDto) {
const existing = await this.users.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email is already in use', {
errorCode: 'EMAIL_TAKEN',
});
}
return this.users.insert(dto);
}
async findOne(id: string) {
const user = await this.users.findById(id);
if (!user) {
throw new NotFoundException('User not found', { errorCode: 'USER_NOT_FOUND' });
}
return user;
}
}
Careful with "check, then insert": Two requests can pass the
findByEmailcheck 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:
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() {}
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>;
}
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');
}
}
// 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: truestrips any property that has no validation decorator.forbidNonWhitelisted: truegoes further and rejects the request instead of silently stripping.transform: trueturns plain payloads into DTO class instances and converts path and query values to the types you declared.
A DTO looks like this:
// src/users/dto/create-user.dto.ts
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsOptional()
@IsString()
@MaxLength(50)
displayName?: string;
}
Four gotchas from the docs
Every property needs at least one decorator. With
whitelist: true, a property with no decorator gets stripped. If your field "disappears," this is usually why.Use concrete classes. TypeScript doesn't emit metadata for generics or interfaces, so the pipe can't validate them.
Don't use
import typefor 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 inlinetypemodifier.Arrays aren't validated by default.
@Body() dtos: CreateUserDto[]skips the elements. Wrap the array in a class, or useParseArrayPipe({ 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()andz.url(). On Zod 3, writez.string().email()andz.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>;
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(),
);
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);
}
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);
}
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);
}
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),
});
// 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:
const port = config.getOrThrow<number>('PORT');
Three habits I'd keep:
One place reads the environment. Everything else asks
ConfigService.Fail early and loudly.
getOrThrowoverget.Never commit secrets. Real values come from your platform's secret store, not from a
.envfile in git.
Still using Joi? It keeps working, but you need Joi v18 or newer, and Joi-specific settings move under validationOptions.libraryOptions.
8. Make errors useful
Clients need to react to errors without parsing English sentences. Nest 12 helps with a new errorCode option on every HttpException:
throw new BadRequestException('Password is too weak', {
errorCode: 'WEAK_PASSWORD',
});
The code is added to the response body. Your frontend can now do if (error.errorCode === 'WEAK_PASSWORD') and stop matching message strings. Message text can change. Codes shouldn't.
For bigger apps: keep HTTP out of your business code
If you want services that don't know about HTTP at all, throw your own domain errors and map them in one place:
// src/common/domain-error.ts
export class DomainError extends Error {
constructor(
message: string,
readonly code: string,
readonly httpStatus: number,
) {
super(message);
}
}
export class EmailTakenError extends DomainError {
constructor() {
super('Email is already in use', 'EMAIL_TAKEN', 409);
}
}
// src/common/domain-error.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { DomainError } from './domain-error.js';
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(error: DomainError, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost;
const response = host.switchToHttp().getResponse();
httpAdapter.reply(
response,
{ statusCode: error.httpStatus, error: error.code, message: error.message },
error.httpStatus,
);
}
}
// in a module, 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:
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.ipandreq.ipsnever 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 appUse a hop count or a subnet, not
true. If your app is ever reachable without going through the proxy, clients can spoofX-Forwarded-For. On Fastify, set the adapter'strustProxyoption instead. This goes inmain.ts, notsetupApp(), becauseINestApplicationhas noset()and your tests don't need it.Second, have the throttler key on the forwarded address. Once
trust proxyis set correctly, plainreq.ipis often enough. If you need explicit control, extend the guard the way the docs do and register it in place ofThrottlerGuard:// 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);
// 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
JwtModuleis registered with a secret, for example withJwtModule.registerAsyncreadingJWT_SECRETfromConfigService. Without a secret,verifyAsynchas nothing to check tokens against.Global guards run in the order you register them. List
ThrottlerGuardbeforeAuthGuard, 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
UsersRepositoryearlier). 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
synchronizeis 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
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 {}
// 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:
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,HEADandOPTIONS. APOSTretry is skipped unless you say@Retry({ idempotent: true }), which means "running this twice is harmless."Protect client retries with idempotency keys.
@Idempotent()from@nestjs/idempotencyreturns the stored response when a client repeats a request with the sameIdempotency-Key. RegisterIdempotencyModulebeforeResilienceModule, 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' },
);
}
}
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);
}
}
// 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')]);
}
}
// 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:
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):
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: falseincreateObserveModule().
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();
});
});
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',
},
},
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));
});
This test only means something because of setupApp(app). Without it, the unknown isAdmin field would sail through.
A few notes:
@nestjs/testingis test-runner agnostic. Vitest, Jest, whatever you like.If you set up Vitest by hand, remember that Nest's DI needs decorator metadata, and not every transformer emits it. Starting from a generated project saves you this headache.
Test the unhappy paths: invalid input, missing auth, duplicate data, a dependency that times out. The happy path usually works already.
14. Performance without drama
I'll keep this short, because most Nest performance problems aren't framework problems.
Keep request scope rare. We covered this in section 5.
Don't block the event loop. Heavy CPU work (image processing, big reports, password hashing in loops) should move to a queue or a worker. The docs have a Queues chapter.
Cache what's expensive and rarely changes, using Nest's caching module, and always decide how it expires.
Measure before switching to Fastify. Nest has an official Fastify adapter and a performance chapter. It can help, but profile first so you're fixing the real bottleneck.
Build faster in development with the SWC recipe if your compile times are slowing you down.
The checklist
Copy this into your team's wiki:
| Area | Do this |
|---|---|
| Setup | Pick CJS or ESM on purpose. Keep all @nestjs/* on one major version. |
| Structure | Feature modules. Small exports. No giant SharedModule. |
| App setup | One setupApp() used by main.ts and tests. |
| Controllers | Thin. Input in, service call, result out. |
| DI | Singletons by default, 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:
Put your setup in one
setupApp()and use it in your tests.Validate input and config, so bad data fails early and loudly.
Put a timeout on every call to something you don't control. 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)