NestJS and Prisma
A NestJS and Prisma backend through the eyes of a .NET developer: modules and DI, the path of a request through guards, interceptors and pipes, working with the database through Prisma 7 and getting the service ready for production. NestJS mirrors ASP.NET Core in many ways, so interviewers expect not only “how” but also “how is it different”.
Before this topic: The Node.js runtime, Dependency injection.
How is a NestJS application structured?
Section titled “How is a NestJS application structured?”What it is
NestJS is a backend framework for Node.js and TypeScript. It sets the application structure and provides a DI container, while Express or Fastify does the actual HTTP work.
Building blocks
- Module (
@Module()) — a group of related classes: controllers, providers, imports of other modules. - Controller (
@Controller('orders')) — handles requests;@Get(':id')on a method defines a route. - Provider (
@Injectable()) — a service, repository or API client. The container creates it and passes it into constructors.
How to use
- Mark a service with
@Injectable()and add it to the module’sproviders. - Ask for the service as a controller constructor parameter.
- In
main.tscallNestFactory.create(AppModule)andapp.listen(3000).
Common mistake
Forgetting a provider in providers or not exporting it from another module. The app won’t start: Nest can't resolve dependencies of the OrdersController (?).
Takeaway: a module groups controllers and providers, and the container wires them through constructors.
How it works
NestFactory.create(AppModule)walks the module tree throughimports.- For each class the container reads
design:paramtypes— the constructor parameter types TypeScript records withemitDecoratorMetadata. - Providers are created in dependency order and belong to their module.
- A module sees its own providers and whatever its imported modules export.
Mapping to ASP.NET Core
| NestJS | ASP.NET Core |
|---|---|
main.ts, NestFactory.create |
Program.cs, WebApplication.CreateBuilder |
@Module() with exports |
an AddOrders() method; no encapsulation |
@Controller() |
[ApiController] |
providers: [OrdersService] |
builder.Services.AddScoped<OrdersService>() |
DEFAULT, REQUEST, TRANSIENT |
Singleton, Scoped, Transient |
| Express or Fastify | Kestrel |
Pitfalls
- Interfaces. They don’t exist at runtime. An interface gets a token (a
Symbolor a string):{ provide: CLOCK, useClass: SystemClock }and@Inject(CLOCK). The alternative is an abstract class acting as the token. import type. A type imported only as a type is erased, and the container can’t recognise the dependency.- Cycles. Mutually dependent modules need
forwardRef(); usually it’s a sign the shared part should be extracted.
Rule of thumb
One module per business area, only exports go outside, interfaces go through tokens.
Takeaway: DI in NestJS rests on constructor metadata and module boundaries; whatever the metadata can’t express gets a token.
Under the hood
- A decorator on a class makes TypeScript keep the constructor parameter types; NestJS reads them through
reflect-metadata. - The graph is built and singleton providers are created at startup, before
listen. A missing dependency stops the process from starting. In .NET withoutValidateOnBuildthe same error only surfaces on a request. - HTTP is handled by an adapter:
@nestjs/platform-express(Express 5, the default) or@nestjs/platform-fastify(Fastify 5). Decorator-based code is portable; code using@Res()and Express middleware isn’t.
Cost
REQUEST“bubbles up”: a controller that depends on a request-scoped provider is also created per request, together with the whole chain. The docs estimate the latency increase at about 5% with careful design.- Request-scoped providers don’t get lifecycle hooks.
Trade-offs
- Singleton by default is natural for Node.js: there is one thread and no memory races. But a field holding request data in a singleton gets overwritten by concurrent requests between
awaits. - The user and correlation id travel through
AsyncLocalStorage(for example, thenestjs-clslibrary). - Express brings a huge middleware ecosystem; Fastify is noticeably faster in benchmarks but has its own plugins and request objects.
How to check in production
- The startup log:
Please make sure that the argument X at index [0] is available in the Y modulenames the module and the parameter position. - The cost of
REQUESTis measured with a load test before and after.
Takeaway: NestJS is DI and modules on top of Express or Fastify; the graph is checked at startup, everything is a singleton by default, and request state lives in AsyncLocalStorage.
import { Controller, Get, Inject, Injectable, Module, Param } from '@nestjs/common';import { NestFactory } from '@nestjs/core';
// The interface doesn't exist at runtime, so it needs a tokenexport interface Clock { now(): Date; }export const CLOCK = Symbol('CLOCK');
@Injectable() // singleton for the whole application by defaultexport class OrdersService { constructor(@Inject(CLOCK) private readonly clock: Clock) {}
find(id: number) { return { id, checkedAt: this.clock.now().toISOString() }; }}
@Controller('orders')export class OrdersController { // The OrdersService type ends up in the design:paramtypes metadata constructor(private readonly orders: OrdersService) {}
@Get(':id') find(@Param('id') id: string) { return this.orders.find(Number(id)); // GET /orders/42 → { "id": 42, ... } }}
@Module({ controllers: [OrdersController], providers: [OrdersService, { provide: CLOCK, useValue: { now: () => new Date() } }], exports: [OrdersService], // without exports other modules can't see OrdersService})export class OrdersModule {}
async function bootstrap() { const app = await NestFactory.create(OrdersModule); // Express by default await app.listen(3000);}void bootstrap();- How is a NestJS module different from registrations in
builder.Services? — Encapsulation: a provider is visible only in its module and in modules that import it, if it’s inexports. - How do you inject an interface implementation? — With a token:
{ provide: TOKEN, useClass: Impl }and@Inject(TOKEN), or an abstract class as the token. - What is a dynamic module? — A module configured at import time:
ConfigModule.forRoot(options), the counterpart ofAddXxx(options => ...). - What is
@Global()for and why not overuse it? — Its exports are visible everywhere without an import; handy for configuration, but it blurs module boundaries. - Why does request scope slow the app down? — It bubbles up the chain: every consumer is re-created on each request.
How is a request processed in NestJS: middleware, guards, interceptors, pipes, exception filters?
Section titled “How is a request processed in NestJS: middleware, guards, interceptors, pipes, exception filters?”What it is
Between the incoming request and the controller method NestJS calls several kinds of handlers:
| Element | Job |
|---|---|
| Middleware | general processing: CORS, security headers |
| Guard | whether to let the request in: authentication, roles |
| Interceptor | code before and after the handler: timing, cache |
| Pipe | validating and transforming arguments |
| Exception filter | turning an exception into an HTTP response |
How to use
- Describe the request body with a DTO class and class-validator decorators:
@IsEmail(),@IsInt(),@Min(1). - Enable
ValidationPipeglobally. - An invalid body gets a 400 with a list of errors, and the controller method isn’t called.
Common mistake
Declaring a DTO as an interface or a type. They don’t exist at runtime, so ValidationPipe lets any body through unchecked.
Takeaway: a guard decides “let it in or not”, a pipe — “is the data valid”, a filter — “how to answer an error”.
How it works
- Middleware: global, then module-bound. The handler isn’t chosen yet.
- Guards: global → controller → route.
falsebecomes a 403. - Interceptors before the handler — in the same order.
- Pipes: global → controller → route → parameter.
- The controller method.
- Interceptors after — in reverse order, from route to global.
- On an exception a filter is looked up: route, then controller, then global. Only one runs.
ValidationPipe
whitelist— strips fields without decorators.forbidNonWhitelisted— returns 400 instead of stripping.transform— turns JSON into a DTO instance and converts:idto anumber.
Compared with ASP.NET Core
| NestJS | ASP.NET Core |
|---|---|
| Guard | authorization filter, [Authorize] |
| Interceptor | action and result filters |
ValidationPipe |
model binding and the automatic 400 from [ApiController] |
| Exception filter | exception filter, IExceptionHandler |
Pitfalls
app.useGlobalGuards()creates the object outside DI. Register{ provide: APP_GUARD, useClass: AuthGuard }instead.- Without
whitelistanisAdminfield from the body reaches the repository — mass assignment.
Takeaway: a request goes middleware → guards → interceptors → pipes → handler → interceptors, and an exception is caught by the nearest filter.
Under the hood
- Guards, interceptors and filters receive an
ExecutionContextorArgumentsHost— a wrapper over HTTP, WebSocket and microservices. One guard works for any transport. ValidationPipetakes the parameter type from the method metadata, creates an instance with class-transformer and checks it with class-validator.next.handle()in an interceptor returns an RxJSObservable. An interceptor may skip the handler entirely — that’s howCacheInterceptorserves a cached response.
Cost
- class-transformer and class-validator mean reflection and allocations on every request; with large arrays in the body it shows.
enableImplicitConversionconverts types silently: the string"false"in a query becomestrue.
Trade-offs
- class-validator: one DTO class for validation and docs, but the decorators duplicate types. Standard Schema (
StandardSchemaValidationPipe,@Body({ schema })) infers the type from the schema. @nestjs/swaggerbuilds OpenAPI from controllers and DTOs, and the CLI plugin adds@ApiProperty()for you.- One global filter defines a single error format, for example ProblemDetails.
How to check in production
- The share of 400s and 403s per route shows where requests get cut off.
- Handler time is separated from the whole chain with a timing interceptor or tracing.
Takeaway: each layer sees more than the previous one; global elements go in as APP_* providers, validation runs with whitelist, and class-free schemas in NestJS 12 go through Standard Schema.
import { Body, CanActivate, Controller, ExecutionContext, Injectable, Module, Post, UseGuards, ValidationPipe,} from '@nestjs/common';import { NestFactory } from '@nestjs/core';import { IsEmail, IsInt, Min } from 'class-validator';
// The DTO is a class: class-validator decorators survive to runtimeexport class CreateOrderDto { @IsEmail() email!: string; @IsInt() @Min(1) quantity!: number;}
@Injectable()export class ApiKeyGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined> }>(); return req.headers['x-api-key'] === process.env.API_KEY; // false → 403 Forbidden }}
@Controller('orders')@UseGuards(ApiKeyGuard)export class OrdersController { @Post() create(@Body() dto: CreateOrderDto) { return { ...dto, status: 'created' }; // only a valid DTO gets here }}
@Module({ controllers: [OrdersController] })export class AppModule {}
async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); await app.listen(3000);}void bootstrap();// POST /orders { "email": "x", "quantity": 0, "isAdmin": true } → 400:// property isAdmin should not exist, email must be an email, quantity must not be less than 1import { ArgumentsHost, CallHandler, Catch, ExceptionFilter, ExecutionContext, Injectable, Logger, Module, NestInterceptor,} from '@nestjs/common';import { APP_FILTER, APP_INTERCEPTOR, HttpAdapterHost } from '@nestjs/core';import { Observable, tap } from 'rxjs';
@Injectable()export class TimingInterceptor implements NestInterceptor { private readonly logger = new Logger('Timing');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { const started = Date.now(); // before the handler return next.handle().pipe( // after the handler: OrdersController.find 12ms tap(() => this.logger.log(`${context.getClass().name}.${context.getHandler().name} ${Date.now() - started}ms`)), ); }}
export class OrderNotFoundError extends Error {}
// Domain error → 404: the service knows nothing about HTTP@Catch(OrderNotFoundError)export class OrderNotFoundFilter implements ExceptionFilter { constructor(private readonly adapterHost: HttpAdapterHost) {}
catch(error: OrderNotFoundError, host: ArgumentsHost) { const { httpAdapter } = this.adapterHost; // works with both Express and Fastify const body = { statusCode: 404, message: error.message }; httpAdapter.reply(host.switchToHttp().getResponse(), body, 404); }}
@Module({ providers: [ // Global and with DI: this way they also work in e2e tests { provide: APP_INTERCEPTOR, useClass: TimingInterceptor }, { provide: APP_FILTER, useClass: OrderNotFoundFilter }, ],})export class CoreModule {}- How is a guard different from middleware? — A guard knows which handler will run (
ExecutionContext,@Roles()metadata); middleware doesn’t. - Why is a DTO a class and not an interface? — An interface is erased at compile time, leaving
ValidationPipenothing to check. - How does a guard read
@Roles('admin')? — WithReflector.getAllAndOverrideover the handler and the class. - Why doesn’t a global pipe from
main.tswork in an e2e test? — The test builds the app from the module andmain.tsnever runs; you needAPP_PIPE. - Will two exception filters handle the same exception? — No, only the nearest one: route, then controller, then global.
How do you work with the database through Prisma?
Section titled “How do you work with the database through Prisma?”What it is
Prisma is an ORM for Node.js and TypeScript. Models are described not as classes but in schema.prisma using Prisma’s own schema language.
How to use
- Describe the
UserandOrdermodels and the relation between them inschema.prisma. npx prisma migrate dev --name init— create an SQL migration and apply it to the local database.npx prisma generate— generate a client typed for your models.- In code:
prisma.user.findMany(),prisma.order.create({ data }).
Related data
include: { orders: true }— load the whole relation, likeIncludein EF Core.select: { email: true }— take only the fields you need, like aSelectprojection.
Common mistake
Editing schema.prisma and forgetting prisma generate. The client doesn’t know about the new field, and TypeScript complains even though the column already exists in the database.
Takeaway: the schema is the source of truth, migrate changes the database, generate changes the client.
How it works
migrate devis for development only. It replays the migration history in a shadow database, detects drift, writes a new migration from schema changes and applies it. On drift it offers to reset the database.migrate deployis for CI and production: it applies pending migrations, with no shadow database and no reset. Applied ones are recorded in the_prisma_migrationstable.- Since Prisma 7 seeding runs explicitly:
prisma db seed.
Prisma and EF Core
| Task | Prisma | EF Core |
|---|---|---|
| Model | schema.prisma |
classes and DbContext |
| New migration | migrate dev |
dotnet ef migrations add |
| Relations | include, select |
Include, Select |
| Update | update() writes immediately |
tracker until SaveChanges |
| Raw SQL | $queryRaw with a template |
FromSql with interpolation |
N+1
include issues a separate query per relation with WHERE ... IN, two queries in total. Counts go through _count, lookups by a list through where: { id: { in: ids } }. With the relationJoins preview flag and relationLoadStrategy: 'join' a relation loads in a single query with a JOIN.
Transactions
- A nested write (
createwithorders: { create: [...] }) — parent and children atomically. $transaction([a, b])— independent queries, all or nothing.$transaction(async (tx) => ...)— logic between queries; the default timeout is 5 s.
Pitfalls
- A
BigIntfrom the database doesn’t serialize to JSON:JSON.stringifythrows aTypeError. - A unique constraint violation arrives as a
PrismaClientKnownRequestErrorwith codeP2002; map it to 409 in an exception filter.
Takeaway: migrate dev is for development, deploy is for production, and N+1 is fixed with nested select and _count.
Under the hood (Prisma 7)
- The
prisma-clientgenerator replacesprisma-client-js: the client is generated into your code (outputis required) rather than intonode_modules. - There is no Rust engine: TypeScript translates the query to SQL and the driver runs it —
pgthrough@prisma/adapter-pg. Pool size and timeouts come from the driver settings, and their defaults differ from Prisma 6. prisma.config.tsholds the schema path, migrations and the database URL; the CLI no longer reads.envby itself.$useis gone; cross-cutting logic is written with Client Extensions ($extends).- The package is ESM; a CommonJS NestJS project sets
moduleFormat = "cjs"in the generator.
Cost
- No change tracker: an aggregate is saved with explicit calls inside a transaction.
- An interactive transaction holds a connection for the whole callback; an HTTP call inside it drains the pool.
$queryRawwith a template string parameterizes values, while$queryRawUnsafewith string concatenation opens the door to SQL injection.
Trade-offs
| ORM | Strength | Cost |
|---|---|---|
| Prisma | exact types, migrations out of the box | code generation, its own DSL |
| TypeORM | entity classes with decorators, closest to EF Core | result type ignores select |
| Drizzle | schema in TypeScript, SQL-like API without codegen | thinner abstraction over SQL |
As of October 2026 the latest tag of the prisma package points to a version 8 RC, so pin version 7 explicitly.
How to check in production
log: ['query']inPrismaClientprints every SQL statement: N+1 shows up as repeats.pg_stat_statementsshows the most frequent and most expensive queries.
Takeaway: Prisma 7 is a typed query builder on top of a driver; you pay with explicit transactions and raw SQL wherever the DSL falls short.
generator client { provider = "prisma-client" output = "../src/generated/prisma" moduleFormat = "cjs" // only for a CommonJS project}
datasource db { provider = "postgresql" // the URL lives in prisma.config.ts}
model User { id Int @id @default(autoincrement()) email String @unique orders Order[]}
model Order { id Int @id @default(autoincrement()) total Decimal @db.Decimal(12, 2) createdAt DateTime @default(now()) userId Int user User @relation(fields: [userId], references: [id])
@@index([userId])}import { Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';import { ConfigService } from '@nestjs/config';import { PrismaPg } from '@prisma/adapter-pg';import { PrismaClient } from './generated/prisma/client';
@Injectable() // singleton: one client and one connection pool per processexport class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown { constructor(config: ConfigService) { super({ adapter: new PrismaPg({ connectionString: config.getOrThrow<string>('DATABASE_URL') }) }); } async onModuleInit() { await this.$connect(); } // database unavailable — startup fails async onApplicationShutdown() { await this.$disconnect(); } // after the HTTP server has closed}
@Injectable()export class OrdersRepository { constructor(private readonly prisma: PrismaService) {}
// N+1: one query for the users and one more for each of them async countsSlow() { const users = await this.prisma.user.findMany(); return Promise.all(users.map(async (u) => ({ email: u.email, orders: await this.prisma.order.count({ where: { userId: u.id } }) }))); }
// No N+1: the relation and the count in a single client call async withOrders() { return this.prisma.user.findMany({ select: { email: true, _count: { select: { orders: true } }, orders: { select: { id: true, total: true } } }, }); // result type: { email; _count: { orders }; orders: { id; total }[] }[] }
// Interactive transaction: both queries between BEGIN and COMMIT async placeOrder(email: string, total: number) { return this.prisma.$transaction(async (tx) => { const user = await tx.user.upsert({ where: { email }, create: { email }, update: {} }); return tx.order.create({ data: { userId: user.id, total } }); }, { timeout: 5_000 }); // 5 s is also the default; longer — rollback }}- How is
migrate devdifferent frommigrate deploy? — Dev creates migrations via a shadow database and may offer a reset; deploy only applies ready-made ones. - How does Prisma load
include? — With a separate query per relation usingIN; withrelationJoins, in a single query with a JOIN. - How do you implement optimistic concurrency? — A
versionfield andupdateManywithwhere: { id, version }; acountof 0 means a conflict. - Why is
$queryRawsafe and$queryRawUnsafenot? — The first turns template substitutions into parameters, the second concatenates a string. - What changed in Prisma 7? — The
prisma-clientgenerator, driver adapters, no Rust engine,prisma.config.ts, ESM.
How do you get a NestJS service ready for production?
Section titled “How do you get a NestJS service ready for production?”What it is
A production service starts with validated configuration, writes readable logs, reports its health and shuts down cleanly.
How to use
- Configuration.
ConfigModule.forRoot({ isGlobal: true })reads.envand environment variables; values come fromconfig.getOrThrow('DATABASE_URL'). - Logs.
nestjs-pinowrites JSON with a request id instead of console text. - Health check.
@nestjs/terminus: an endpoint checks the database and answers 200 or 503. - Shutdown.
app.enableShutdownHooks()inmain.ts; without it shutdown hooks don’t run on SIGTERM. - Tests.
@nestjs/testingbuilds a module with overridden providers.
Common mistake
Reading process.env.X all over the code. A typo in a variable name shows up in production as undefined in the middle of a request.
Takeaway: validated config, JSON logs, a health check and shutdown hooks are the minimum for production.
How it works
- Configuration.
validationSchemachecks variables at startup. In NestJS 12 any Standard Schema works, for example Zod. - Logs.
LoggerModule.forRoot()logs every request;app.useLogger(app.get(Logger))routes Nest’s own logs there too, andbufferLogs: trueholds startup logs until pino is ready. - Health checks. Readiness checks the database through
PrismaHealthIndicator.pingCheck, liveness only checks that the process responds. More on the difference in Observability. - Shutdown. On SIGTERM:
onModuleDestroy→beforeApplicationShutdown→ the HTTP server closes →onApplicationShutdown.
Pitfalls
app.useGlobalPipes()frommain.tsdoesn’t make it into an e2e test: the test builds the app from the module. Global pipes, guards and filters are registered viaAPP_PIPE,APP_GUARD,APP_FILTER.- The shell form
CMD npm startin a Dockerfile: the shell receives the signal, not Node. Use the exec formCMD ["node", "dist/main.js"]. - Signal support on Windows is limited, so test shutdown in a Linux container.
Rule of thumb
Config with a schema, logs as pino to stdout, readiness with the database, liveness without it, shutdown through shutdown hooks.
Takeaway: NestJS gives you the building blocks, but config validation, logging, health checks and shutdown are switched on explicitly.
Under the hood
enableShutdownHooks()subscribes to process signals. Request-scoped providers don’t get the hooks.onModuleDestroyfires before the HTTP server closes. Resources that in-flight requests need — the Prisma pool — are closed inonApplicationShutdown.gracefulShutdownTimeoutMsinTerminusModule.forRootruns inbeforeApplicationShutdown: the health check already returns 503, the load balancer drops the instance, and in-flight requests finish. The pause plus the shutdown must stay belowterminationGracePeriodSeconds(30 s by default).
Cost and performance
- pino writes JSON faster than most loggers;
pino-prettyis for development only. - Fastify speeds up routing and serialization, but Express middleware and
@Res()code using the Express API have to be rewritten. CacheInterceptorfrom@nestjs/cache-managercaches GET responses; with several instances you need a shared Redis.
Trade-offs
- Unit tests use
overrideProviderwith mocks. E2E tests usecreateNestApplication()and supertest against PostgreSQL in Testcontainers afterprisma migrate deploy: a Prisma mock won’t catch an SQL error. - In NestJS 12 ESM projects default to Vitest, CommonJS projects to Jest.
- Several services — a Nest CLI monorepo (
apps/,libs/) or Nx.
How to check in production
kill -TERMon staging: the logs should show 503 from readiness, requests completing, the database disconnecting and exit code 0.- Latency and 5xx — from pino logs and tracing via OpenTelemetry or the new
@nestjs/observe.
Takeaway: production is the shutdown order, verifiable config and tests against a real database, not just handler code.
import { Controller, Get, Module, ValidationPipe } from '@nestjs/common';import { ConfigModule } from '@nestjs/config';import { APP_PIPE, NestFactory } from '@nestjs/core';import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';import { HealthCheck, HealthCheckService, PrismaHealthIndicator, TerminusModule } from '@nestjs/terminus';import { Logger, LoggerModule } from 'nestjs-pino';import { z } from 'zod';import { PrismaService } from './prisma.service';
@Controller('health')export class HealthController { constructor(private health: HealthCheckService, private db: PrismaHealthIndicator, private prisma: PrismaService) {}
@Get('live') // is the process alive — no external dependencies live() { return { status: 'ok' }; }
@Get('ready') @HealthCheck() // database down or shutting down → 503 ready() { return this.health.check([() => this.db.pingCheck('database', this.prisma)]); }}
@Module({ imports: [ ConfigModule.forRoot({ // no DATABASE_URL — the process fails at startup isGlobal: true, validationSchema: z.object({ DATABASE_URL: z.url(), PORT: z.coerce.number().default(3000) }), }), LoggerModule.forRoot({ pinoHttp: { level: process.env.LOG_LEVEL ?? 'info' } }), // JSON to stdout TerminusModule.forRoot({ gracefulShutdownTimeoutMs: 10_000 }), // return 503 for 10 s before shutdown ], controllers: [HealthController], providers: [PrismaService, { provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, transform: true }) }],})export class AppModule {}
async function bootstrap() { const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { bufferLogs: true }); app.useLogger(app.get(Logger)); // Nest's own logs go through pino too app.enableShutdownHooks(); // without this SIGTERM won't run the shutdown hooks await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); // listen on all interfaces in a container}void bootstrap();import { INestApplication } from '@nestjs/common';import { Test } from '@nestjs/testing';import request from 'supertest';import { afterAll, beforeAll, describe, it } from 'vitest';import { AppModule } from './app.module';import { PrismaService } from './prisma.service';
describe('health (e2e)', () => { let app: INestApplication; const down = () => Promise.reject(new Error('connection refused'));
beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(PrismaService) // like ConfigureTestServices in WebApplicationFactory .useValue({ $runCommandRaw: down, $queryRawUnsafe: down }) // the database is "down" .compile(); app = moduleRef.createNestApplication(); // main.ts doesn't run: APP_PIPE applies, useGlobalPipes doesn't await app.init(); });
afterAll(() => app.close());
it('liveness does not depend on the database', () => request(app.getHttpServer()).get('/health/live').expect(200));
it('readiness without the database → 503', () => request(app.getHttpServer()).get('/health/ready').expect(503));});PrismaHealthIndicator pings the database with $runCommandRaw, and for SQL databases with $queryRawUnsafe('SELECT 1'), so the mock overrides both methods.
- Why is
onModuleDestroynot the best place for$disconnect()? — It runs before the HTTP server closes, leaving in-flight requests without a database. - What is
gracefulShutdownTimeoutMsfor? — So the load balancer sees 503 on readiness and stops sending traffic before the process closes the server. - How do you get the request id into every log line? —
nestjs-pinokeeps the request logger inAsyncLocalStorage, andPinoLoggerin any service logs with that id. - Why does readiness check the database and liveness doesn’t? — A restart doesn’t fix a database outage; liveness with a database check would restart every pod at once.
- How do you test against a real database? — Testcontainers starts PostgreSQL,
prisma migrate deployapplies the schema,Test.createTestingModulebuilds the app.