---
name: nodejs-development
description: Build production-grade Node.js back-end services in TypeScript — framework choice (Express, Fastify, NestJS, Hono), strict tsconfig and ESM, schema validation at the edge, central error handling, validated config, data access and transactions, resilient outbound HTTP, auth, pino logging, graceful shutdown, background jobs and security basics. Use this whenever the user asks to write, scaffold, refactor or debug a Node/TypeScript API, route, controller, middleware or worker; edits package.json, tsconfig.json, server.ts, app.ts, main.ts, app.module.ts or .env; asks about Zod, TypeBox, Prisma, Drizzle, Knex, TypeORM, JWT, sessions, OAuth, BullMQ, helmet, rate limiting, OpenAPI, "unhandled promise rejection", "ERR_REQUIRE_ESM" or "how do I do X in Express/Fastify/NestJS". Also apply it when reviewing Node.js back-end code or pull requests, even if the question is about a single handler.
metadata:
  technology: Node.js
  type: development
---

# Node.js Development

A Node service is one process multiplexing thousands of requests on a single thread: anything that blocks, leaks, throws unhandled or trusts its input hurts every request at once. Write each handler assuming hostile input, a slow dependency and a SIGTERM arriving mid-request. Target the active Node LTS unless the project pins a version (`engines`, `.nvmrc`, `.node-version`, Dockerfile base image).

## 1. Pick the framework deliberately

| Framework | Default when | Watch out for |
|---|---|---|
| **Fastify** | New HTTP/JSON APIs; you want schema-first validation, fast serialization, plugin encapsulation | Plugin scoping (`fastify-plugin`) confuses newcomers |
| **NestJS** | Large teams, many modules, want opinionated DI, guards, interceptors, OpenAPI generation | Decorator/`reflect-metadata` magic, heavier startup; can run on Fastify adapter |
| **Express 5** | Existing Express codebases, huge middleware ecosystem, simple services | Slower, no built-in validation or schemas; Express 5 forwards rejected promises, v4 does not |
| **Hono** | Edge/serverless or multi-runtime (Node, Bun, Workers, Deno), small footprint | Smaller ecosystem for server-only concerns (sessions, queues) |

Don't migrate frameworks for fashion. For greenfield: Fastify for a lean service, NestJS when team size and module count justify the structure.

## 2. TypeScript and module setup

```jsonc
// tsconfig.json (build); pair with "type": "module" in package.json
{ "compilerOptions": {
    "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext",
    "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true,
    "verbatimModuleSyntax": true, "isolatedModules": true,
    "outDir": "dist", "sourceMap": true, "skipLibCheck": true } }
```

- **ESM for new projects** (`"type": "module"`, relative imports with `.js` extensions under `NodeNext`). Stay CommonJS only when a key dependency or tooling (some NestJS setups, older Jest) forces it; don't mix both in one package. Recent Node versions can `require()` synchronous ESM, but don't design around it.
- Dev loop with `tsx` (or Node's built-in type stripping on recent versions, which only handles erasable syntax); production runs compiled JS from `dist/`. `tsc --noEmit` in CI regardless.
- Layout: `src/modules/<feature>/{routes,service,repository,schemas}.ts`, `src/config.ts`, `src/app.ts` (builds the app, no `listen`), `src/server.ts` (listens, handles signals). Separating `app` from `server` is what makes in-process testing possible.

## 3. Validate at the edge, trust inside

- Every request body, query, params and header you read is parsed by a schema: **TypeBox or Zod** with Fastify (type provider), **Zod** with Express/Hono, **class-validator + ValidationPipe** (`whitelist: true, forbidNonWhitelisted: true`) or Zod pipes in NestJS.
- Infer TS types from the schema; never hand-write a parallel interface.
- Validate responses too (Fastify response schemas also strip unlisted fields, preventing accidental leaks like `passwordHash`).

```ts
const CreateOrder = z.object({ sku: z.string().min(1), qty: z.number().int().positive().max(100) });
app.post('/orders', async (req, res) => {
  const input = CreateOrder.parse(req.body);        // throws ZodError → error handler → 400
  res.status(201).json(await orders.create(input, req.user.id));
});
```

## 4. Errors: operational vs programmer

- **Operational** (bad input, not found, conflict, dependency timeout): throw typed domain errors (`NotFoundError`, `ConflictError`) with a code; one central handler maps them to HTTP status and a stable body (RFC 9457 problem+json is a good default). Never leak stack traces or SQL to clients.
- **Programmer** errors (TypeError, invariant broken): log with full context, return 500, and treat as a bug.
- Async errors must reach the handler: Fastify and Express 5 handle rejected async handlers; in Express 4 wrap handlers or upgrade. Always `await` or `return` promises; floating promises are banned (`@typescript-eslint/no-floating-promises`).
- Central handler: Fastify `setErrorHandler`, Express 4-arg middleware registered last, NestJS exception filters.
- Process level: on `uncaughtException` / `unhandledRejection`, log fatally and **exit non-zero**; let the orchestrator restart. Continuing after an uncaught exception runs in an unknown state.

## 5. Config and secrets

```ts
// src/config.ts: the only file that reads process.env
const Env = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_ISSUER: z.string().url(),
});
export const config = Env.parse(process.env);     // fail fast at boot with a clear message
```

- Inject `config` (or slices of it); `process.env.X` scattered through the code is untestable and unvalidated.
- `.env` only for local dev (`node --env-file=.env` or dotenv), git-ignored, with a committed `.env.example`. Production secrets come from the platform's secret manager, mounted as env or files; never baked into images or logged.

## 6. Data access

| Tool | Choose when | Trade-off |
|---|---|---|
| **Prisma** | Productivity, schema-first migrations, typed client | Less SQL control; check generated queries; `$transaction` for multi-step writes |
| **Drizzle** | SQL-shaped, lightweight, typed; serverless-friendly | Younger ecosystem, you design more yourself |
| **Knex / Kysely** | Query builder, full SQL control (Kysely for types) | You own mapping and migrations discipline |
| **TypeORM / MikroORM** | Team wants Unit of Work/entity patterns (common with NestJS) | Lazy-loading and N+1 surprises; review generated SQL |

- Parameterized queries only; raw SQL through tagged templates (`` sql`...` ``, `Prisma.sql`), never string concatenation.
- One pool per process sized to `DB max_connections / replicas`; in serverless use a proxy/pooler (PgBouncer, RDS Proxy, driver adapters).
- Transactions around every multi-write invariant; keep them short, no outbound HTTP inside a transaction.
- Repositories own queries; services never build SQL. Migrations are versioned files run in CI/CD, not `synchronize: true`.

## 7. Outbound HTTP

- Use global `fetch` (undici) or `undici.request`; every call has a timeout: `fetch(url, { signal: AbortSignal.timeout(2000) })`. Combine with the request's own abort via `AbortSignal.any([...])`.
- Retry only idempotent operations, on 5xx/429/network errors, with exponential backoff + jitter and a cap; honour `Retry-After`. Send idempotency keys for retried POSTs.
- Wrap each dependency in a typed client module; validate its responses with a schema. Add a circuit breaker (e.g. `opossum`) for flaky dependencies on hot paths.

## 8. Auth

- Don't roll crypto: `argon2`/`bcrypt` for passwords, `jose` for JWT, established OAuth/OIDC libraries or a managed IdP.
- JWT: verify signature, `alg` allow-list, `iss`, `aud`, `exp`; short-lived access tokens, rotated refresh tokens. Sessions (httpOnly, `Secure`, `SameSite` cookies, server-side store like Redis) are simpler and revocable for first-party web apps.
- Authorize per resource in the service layer ("can this user act on order 42?"), not only per route.

## 9. Logging, shutdown, jobs, security

- **pino** (Fastify's built-in logger; `nestjs-pino`/`pino-http` elsewhere): JSON logs, request id on every line via child loggers, `redact: ['req.headers.authorization', 'req.headers.cookie', '*.password']`. No `console.log` in services.
- **Graceful shutdown** on SIGTERM/SIGINT: fail readiness, stop accepting connections (`server.close()` / `app.close()`), drain in-flight requests and workers, close DB/Redis/queues, then exit; force-exit after a timeout shorter than the orchestrator's grace period.
- **Background jobs** with BullMQ (Redis) or a DB-backed queue (pg-boss, Graphile Worker) in a separate worker process; jobs are idempotent, have retries with backoff and a dead-letter path. Never do slow work after sending the HTTP response in the same process "fire and forget".
- **Security**: `helmet`/`@fastify/helmet`, CORS allow-list, rate limiting (`@fastify/rate-limit`, `express-rate-limit`, backed by Redis when multi-instance), body size limits, `npm audit`/Dependabot/Renovate in CI, lockfile committed, `npm ci` in builds, run as non-root.
- **OpenAPI**: generate from the same schemas (`@fastify/swagger`, `@nestjs/swagger`, `zod-to-openapi`) and publish it; contract drift is a bug.

## Deliverable format

When writing or reviewing a Node service change, output:

```
## Summary        what changed and why (1–3 lines)
## Code           files with full, compiling TypeScript (schemas, handler, service, repository)
## Errors         which domain errors are thrown and their HTTP mapping
## Config         new env vars (added to config schema and .env.example)
## Tests          unit + in-process HTTP tests added
## Risks          migrations, breaking API changes, rollout notes
```

## Anti-patterns to reject

- `process.env.X` read outside the config module; unvalidated or missing-default config.
- `req.body` used without a schema; hand-written types duplicating schemas; `any` on request data.
- Floating promises, `async` Express 4 handlers without error forwarding, empty `catch`, `catch (e) { console.log(e) }`.
- Keeping the process alive after `uncaughtException`.
- SQL built by string concatenation; `synchronize: true` in production; a new pool per request.
- `fetch` without a timeout; retrying non-idempotent calls blindly.
- Custom JWT parsing, `jwt.decode` instead of verify, secrets committed or logged.
- `server.listen` inside `app.ts`, making the app untestable in-process.
- Sync APIs (`fs.readFileSync`, `crypto.pbkdf2Sync`) on request paths.
