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.