Master the job description
Study Guide
Every requirement a senior NestJS / Node.js role commonly asks for — explained at senior depth. Read the concept, then the “how to say it” line so you can deliver a crisp, correct answer out loud.
01 · NestJS, TypeScript & the framework model
What they want: you understand why Nest exists. It's a progressive, opinionated Node.js framework written in TypeScript that sits on an HTTP adapter (Express by default, or Fastify) and adds an IoC/DI container, a module system, and decorators. It solves the architecture gap Express leaves open.
Nest combines OOP, FP, and FRP (RxJS in interceptors/microservices). It leans on TypeScript's emitted decorator metadata (reflect-metadata) to know a constructor parameter's type — which is the injection token. That's why tsconfig needs experimentalDecorators + emitDecoratorMetadata, and why DI works on classes, not interfaces.
02 · Modules & encapsulation
Core: a module (@Module) groups related controllers and providers; the app is a tree of modules rooted at AppModule. Providers are private to their module unless exported; exports is the module's public API and imports brings another module's exports in.
- Every module is a singleton — export a provider once and all importers share one instance.
- Listing the same class in two modules'
providerscreates two instances (a state-sharing bug). @Global()exposes exports everywhere without importing — reserve it for cross-cutting infra (config, DB, logging); overusing it hides coupling.
@Global() except for true infrastructure.”03 · Controllers & routing
Core: controllers (@Controller('users')) map routes to handlers (@Get(':id'), @Post()). Return a value and Nest serializes it to JSON (200, or 201 for POST). Read inputs with @Param, @Query, @Body, @Headers, often combined with a pipe (@Param('id', ParseIntPipe)).
@Res() switches to Express-mode: you must send the response yourself and lose interceptors/@HttpCode. Use @Res({ passthrough: true }) if you only need to set a cookie/header.* must be named: @Get('files/*') → @Get('files/*splat').04 · Providers & dependency injection
Core: a provider is anything injectable (@Injectable() services, repositories, factories, values). A consumer declares a dependency by type in its constructor; that type is the token. The module registers a provider for the token; at bootstrap the container builds the graph transitively and instantiates bottom-up, caching singletons.
Custom providers give you control:
| Provider | Use |
|---|---|
useClass | Resolve a token to a class (swap by env). |
useValue | Inject a constant / mock / external instance. |
useFactory | Build dynamically (async ok), inject deps via inject:[]. |
useExisting | Alias a token to an existing one (same singleton). |
Inject non-class deps (interfaces, config) via a string/symbol token + @Inject(TOKEN) — the ports-and-adapters mechanic.
05 · Injection scopes & lifecycle
Core: three scopes — DEFAULT (singleton, recommended), REQUEST (per request), TRANSIENT (per consumer). Singletons are safe because Node isn't thread-per-request; only store request-specific state in REQUEST scope (or better, AsyncLocalStorage).
ContextIdStrategy to recover performance.Lifecycle hooks (order): onModuleInit → onApplicationBootstrap → [running] → onModuleDestroy → beforeApplicationShutdown → onApplicationShutdown. Shutdown hooks fire only after app.enableShutdownHooks(); they don't run for request-scoped providers.
06 · Dynamic modules & configuration
Core: a dynamic module returns its metadata from a static method so it can be configured. Convention: register() = per-importer, forRoot() = once app-wide, forFeature() = per-feature tweak of a forRoot. Each has an async variant (forRootAsync) taking useFactory+inject so options can come from ConfigService.
The modern implementation is ConfigurableModuleBuilder, which auto-generates the base class, the options token, and the sync/async signatures — removing hand-written boilerplate.
const m = X.forRoot({...}) to a variable and reuse it to share one.07 · The request lifecycle
Memorize this: Incoming request → Middleware → Guards → Interceptors (pre) → Pipes → Handler (→ Service) → Interceptors (post) → Exception filters → Response. Within each level it's global → controller → route; interceptors unwind on the way out, and filters are the only enhancer that resolves route → controller → global.
| Block | Job · has ExecutionContext? |
|---|---|
| Middleware | Pre-routing raw req/res — no context |
| Guard | Authorization — yes (Reflector) |
| Interceptor | AOP before/after (RxJS) — yes |
| Pipe | Validate/transform args — metadata only |
| Filter | Shape errors — ArgumentsHost only |
08 · Pipes & validation
Core: pipes (transform(value, metadata)) validate and transform handler args, running just before the handler inside the exceptions zone. The global ValidationPipe + class-validator on class DTOs is the backbone:
whitelist/forbidNonWhitelisted defend against mass-assignment. Nested objects need @ValidateNested() + @Type(() => Dto). Built-in parse pipes: ParseIntPipe, ParseUUIDPipe, ParseArrayPipe, and v11's ParseDatePipe.
import type erases them and validation silently no-ops.09 · Guards & authorization
Core: a guard (canActivate(ctx) → boolean) decides whether a request proceeds — the home of authorization. Because it has an ExecutionContext, it reads route metadata via Reflector:
RBAC: @Roles('admin') + a RolesGuard comparing to req.user.roles. Global-auth pattern: register the JWT guard as APP_GUARD (everything protected), add @Public() and short-circuit on it. For per-resource rules step up to ABAC with CASL.
10 · Interceptors
Core: interceptors wrap the handler in an RxJS stream (intercept(ctx, next) → Observable), giving before/after logic around next.handle(). Skip next.handle() to override (caching).
- Transform responses —
map(d => ({ data: d })) - Logging/timing —
tap(...) - Timeouts —
timeout(5000)+catchError - Serialization —
ClassSerializerInterceptor(@Exclude/@Expose)
11 · Exception filters & error handling
Core: throw HttpException subclasses (NotFoundException, BadRequestException) and Nest's built-in filter shapes the response; unknown errors → 500. A custom filter (@Catch() + catch(exception, host)) lets you standardize the error body, log, and map domain errors to HTTP.
AllExceptionsFilter pattern: @Catch() (everything) + inject HttpAdapterHost; use ArgumentsHost so it works across HTTP/WS/RPC. Extend BaseExceptionFilter and call super.catch() to keep defaults while adding logging.
12 · Middleware
Core: middleware runs first, with raw req/res/next — logging, CORS, helmet, body parsing, attaching a request id. Class middleware (NestMiddleware) is DI-capable; functional middleware has no deps. Apply via configure(consumer): consumer.apply(LoggerMiddleware).forRoutes('users').
app.use() middleware can't use DI.13 · Configuration & secrets
Core: @nestjs/config loads .env + merges process.env (real env wins). Make it global, cache it, and validate at boot with Joi/zod so a misconfigured deploy crashes immediately instead of failing at request time. Namespace with registerAs('db', ...) for typed, modular config.
12-factor: config that varies per deploy lives in the environment, not the repo. .env is local-dev only (gitignored + dockerignored); prod secrets come from a manager (k8s Secrets, AWS Secrets Manager, Vault). Node 20+ can load env natively with --env-file.
14 · Databases & the repository pattern
Core: Nest integrates TypeORM, Prisma, and Mongoose. TypeORM: forRoot configures the DataSource, forFeature([Entity]) registers per module, inject @InjectRepository(User). Prisma: wrap the generated client in a PrismaService (connect in onModuleInit). Mongoose: @InjectModel over @Schema classes.
The repository pattern keeps the app talking to repositories, not the raw ORM/SQL — so you can swap the store, add caching, and map rows to domain models at the boundary. Don't leak entities (with secrets/relations) straight into API responses.
synchronize: true auto-alters the schema and can drop data — never in production. Use migrations.15 · Transactions & data integrity
Core: wrap multi-step writes in a transaction so they commit or roll back atomically. TypeORM gives three ways: a QueryRunner (most control, must release() in finally), the callback dataSource.transaction(async mgr => ...) (auto commit/rollback), or the community typeorm-transactional decorator. Every operation must share the same EntityManager or it runs outside the transaction.
Across services you can't use a DB transaction — use the Saga pattern (compensating actions) and the transactional outbox for reliable event publishing, with idempotent consumers.
16 · Authentication (JWT / Passport)
Core: issue a short-lived access token on login (JwtService.signAsync, id in sub) and verify it in a guard on protected routes. With Passport, a JwtStrategy extracts and validates the token (return value → req.user) and you protect with AuthGuard('jwt'); without Passport, a hand-rolled guard uses JwtService directly.
Pair access tokens with refresh tokens (rotated, stored, revocable) since JWTs can't be revoked before expiry. Hash passwords with bcrypt/argon2 (never store plaintext or reversible).
17 · Caching
Core: @nestjs/cache-manager (v11 is Keyv-based) gives manual caching (@Inject(CACHE_MANAGER) → get/set/del) and automatic GET caching (CacheInterceptor). Patterns: cache-aside (check → miss → DB → set TTL → invalidate on write), read-through, write-through. Name stale-while-revalidate for instant reads + background refresh.
get() returns undefined on miss in v11.18 · Queues & background jobs
Core: move heavy/slow work off the request path with BullMQ (@nestjs/bullmq, Redis-backed). Producer adds jobs (q.add('name', data, { attempts, backoff, delay, priority })); a @Processor extends WorkerHost and routes by job.name in process(). You get retries, backoff, delays, priorities, and durable persistence.
@Process('name') does not work — switch on job.name.19 · Task scheduling & events
Core: @nestjs/schedule gives @Cron(), @Interval(), @Timeout() and a SchedulerRegistry for dynamic jobs. @nestjs/event-emitter gives in-process pub/sub (emit / @OnEvent) for decoupling side effects from the main flow.
20 · Testing strategy
Core: @nestjs/testing builds a DI graph you can override. Unit: Test.createTestingModule({...}).overrideProvider(X).useValue(mock).compile(), then module.get() (or resolve() for scoped). E2E: createNestApplication() → init() → drive with supertest → app.close().
The pyramid: many fast unit tests (mock collaborators) → fewer integration tests (real DB/Redis via Testcontainers) → a few e2e. Test the five outcomes of a flow: response, DB change, outgoing call, queued message, observability.
21 · The Node.js event loop
Core: Node runs your JS on one thread atop libuv. The loop's phases, in order: timers → pending callbacks → idle/prepare → poll (I/O) → check (setImmediate) → close callbacks. Between every callback, two microtask queues drain: process.nextTick first, then Promises.
Key facts: inside an I/O callback setImmediate beats setTimeout(0); recursive nextTick can starve the loop; the thread pool (default 4) serves fs, dns.lookup, crypto, zlib — but not network I/O (that's kernel-async).
22 · Async patterns & error handling
Core: callbacks → promises → async/await; most core APIs have promise variants (fs/promises, timers/promises). Know the combinators: all (all-or-fast-fail), allSettled (collect partials), race (first to settle), any (first success). Use AbortController/AbortSignal for cancellation and timeouts.
Pitfalls: floating promises (errors vanish), forEach with async (doesn't await), unbounded Promise.all over huge arrays (use a concurrency limiter), and unhandled rejections (process exits by default since Node 15). Distinguish operational errors (handle) from programmer errors (let it crash).
23 · Streams & backpressure
Core: four types — Readable, Writable, Duplex, Transform. Stream large data instead of buffering it into memory. Backpressure stops a fast producer overrunning a slow consumer: write() returns false + the 'drain' event is the manual protocol.
stream.pipeline(): pipe() doesn't forward errors or clean up on failure (FD leaks). Readables are async-iterable — for await (const chunk of stream) respects backpressure automatically.24 · Concurrency: worker_threads, cluster, child_process
Core: Node scales by not blocking the loop and by using more processes/threads.
| Tool | Use |
|---|---|
| worker_threads | CPU-bound JS in-process; shared memory via SharedArrayBuffer. |
| cluster | Scale a stateless HTTP server across cores (processes sharing a port). |
| child_process | Run external programs: spawn (stream), exec (buffer + shell), fork (Node + IPC). |
In containers, N single-process replicas behind a load balancer often replace in-process cluster. Keep instances stateless (state in Redis/DB) so any replica serves any request.
25 · Modules: CommonJS vs ESM
Core: CJS (require/module.exports) is synchronous with free __dirname; ESM (import/export) is async, statically analyzable, supports top-level await, and uses import.meta.dirname. Opt in with "type": "module" or .mjs.
Interop: ESM imports CJS fine; CJS importing ESM was dynamic-import()-only until synchronous require(esm) stabilized in Node 24. The "exports" field defines public entry points and encapsulates internals (no deep imports). NestJS 12 plans a CJS→ESM transition.
26 · Performance & profiling
Core: the #1 rule is don't block the event loop — offload CPU work, avoid sync APIs on the hot path, and prefer native methods. Measure, don't guess: perf_hooks.monitorEventLoopDelay() for loop lag, --cpu-prof / clinic.js / 0x for flamegraphs, heap snapshots for memory.
Other levers: connection pooling, keep-alive, response compression (ideally at the proxy), caching, pagination + streaming for large results, and the Fastify adapter for raw throughput. Distributed state (rate limits, cache, sessions) must live in Redis so replicas agree.
27 · Memory management & leak hunting
Core: V8 uses a generational GC — a fast Scavenger for the young generation (most objects die young) and Mark-Sweep-Compact for the old generation. A leak shows as steadily growing old-space retained size across snapshots.
Common causes: unbounded caches/Maps, forgotten event listeners (MaxListenersExceededWarning), timers never cleared, closures capturing large objects, module-level globals that only grow. Hunt by comparing two heap snapshots over time (retained constructors + retainer paths). WeakMap/WeakRef help where appropriate.
28 · Microservices & transports
Core: a Nest microservice uses a non-HTTP transport (TCP, Redis, NATS, RabbitMQ, Kafka, gRPC) with the same DI/pipes/guards/filters. Request-response uses @MessagePattern + client.send() (cold Observable); events use @EventPattern + client.emit() (fire-and-forget). Throw RpcException; RPC filters return Observables.
Pick gRPC for typed low-latency RPC, Kafka for durable replayable streams, RabbitMQ for reliable work queues. An API gateway (often the HTTP half of a hybrid app) fronts the services and centralizes auth/routing/aggregation. Keep consistency with sagas + outbox + idempotency, not 2PC.
29 · GraphQL
Core: prefer code-first (TS classes generate the SDL — no drift). Resolvers hold @Query/@Mutation/@ResolveField; field resolvers fetch relations lazily. The headline senior topic is the N+1 problem: a field resolver firing per parent over a list → 1+N queries. Fix with DataLoader batching (1+1) — return results in the same order/length as keys, and make the loader per-request.
Subscriptions use graphql-ws (back PubSub with Redis in prod). Federation splits the graph into subgraphs + a gateway via @key + @ResolveReference().
30 · WebSockets & real-time
Core: gateways (@WebSocketGateway) handle @SubscribeMessage events with connection lifecycle hooks. socket.io (IoAdapter) gives rooms/namespaces/acks; ws (WsAdapter) is leaner. Gateways are singletons (can't be request-scoped) — keep per-connection state on the socket.
Scale across instances with the socket.io Redis adapter (pub/sub fans out room events) plus sticky sessions or websocket-only transport. Authenticate in the handshake, not per message. For real-time choices: WebSocket for two-way, SSE for one-way server push, push notifications when the app is closed.
31 · Security hardening
Core: the biggest real risk is the supply chain — lockfile + npm ci, audit/Snyk, minimize deps, --ignore-scripts. App-level: validate all input (whitelist DTOs), limit body size (single-thread DoS), rate-limit, helmet headers, strict CORS, parameterized queries (injection), guard against prototype pollution and ReDoS, hide error internals, run as non-root.
--permission is a seat belt for trusted code, not a sandbox against malicious code. Real isolation is OS-level (containers, seccomp).32 · Logging & observability
Core: structured JSON logs (pino via nestjs-pino) to stdout, with redaction of secrets and a correlation id per request (via nestjs-cls/AsyncLocalStorage) so every line is traceable. Add OpenTelemetry tracing (load the SDK before the app) — traces are span trees sharing a trace id, propagated cross-service via the traceparent header.
Monitor four signals: uptime/health, metrics (incl. event-loop lag, RED), traces, and logs. Alert on guardrails (error rate, p99 latency, loop lag).
33 · CI/CD, Docker & deployment
Core: a multi-stage Dockerfile — build stage compiles + prunes dev deps, runtime stage copies only node_modules + dist onto a small/distroless base, runs as the non-root node user, uses exec-form CMD ["node","dist/main.js"], and handles signals (--init/tini). Set NODE_ENV=production and a V8 heap limit matching the container.
CI runs typecheck + lint + tests on every push (the loop); use npm ci with a committed lockfile; design atomic, zero-downtime deploys and let the orchestrator restart — don't bundle a process manager inside k8s.
34 · Graceful shutdown & health checks
Core: call app.enableShutdownHooks(), then on SIGTERM: fail readiness first (stop new traffic), stop accepting connections, finish in-flight requests, close DB/Redis/brokers, flush logs, exit. Nest awaits promises from the shutdown hooks.
Probes (@nestjs/terminus): liveness = process alive (cheap, no DB) → restart on failure; readiness = can serve now (checks real deps) → removed from the load balancer on failure; startup guards slow boots. In k8s, add a small preStop delay since endpoint removal and SIGTERM race.
35 · Versioning, compression & file handling
Core: API versioning via app.enableVersioning({ type }) — URI (/v1, default), header, media-type, or custom — with @Version('1') per controller/route and VERSION_NEUTRAL for shared routes. Compression (Express compression / @fastify/compress) is best offloaded to a reverse proxy at scale.
File upload: Multer via FileInterceptor + ParseFilePipe (size + magic-number type validation); Multer is incompatible with Fastify (use @fastify/multipart). Streaming files: return a StreamableFile so post-controller interceptors still run.
36 · Soft skills & system-design interviews
What they want: communication, structured thinking, and trade-off awareness. In a design round, clarify requirements (functional + non-functional, scale, read/write ratio) before drawing — most candidates skip this — then sketch a high-level design, deep-dive one component, discuss trade-offs out loud (“I'm trading freshness for speed”), and summarize risks.
For behavioral questions, use STAR (Situation, Task, Action, Result) with a concrete metric. Explain trade-offs to non-technical stakeholders in business terms, and disagree-and-commit in reviews.