Node.js Backend
Framework Selection
| Context |
Choose |
Why |
| Edge/Serverless |
Hono |
Zero-dep, fastest cold starts |
| Performance API |
Fastify |
2-3x faster than Express, built-in schema validation |
| Enterprise/team |
NestJS |
DI, decorators, structured conventions |
| Legacy/ecosystem |
Express |
Most middleware, widest adoption |
Ask user: deployment target, cold start needs, team experience, existing codebase.
Architecture
src/
├── routes/ # HTTP: parse request, call service, format response
├── middleware/ # Auth, validation, rate limiting, logging
├── services/ # Business logic (no HTTP types)
├── repositories/ # Data access only (queries, ORM)
├── config/ # Env, DB pool, constants
└── types/ # Shared TypeScript interfaces
- Routes never contain business logic
- Services never import Request/Response
- Repositories never throw HTTP errors
- For scripts/prototypes: single file is fine — ask "will this grow?"
TypeScript Rules
- Use
import type { } for type-only imports — eliminates runtime overhead
- Prefer
interface for object shapes (2-5x faster type resolution than intersections)
- Prefer
unknown over any — forces explicit narrowing
- Use
z.infer<typeof Schema> as single source of truth — never duplicate types and schemas
- Minimize
as assertions — use type guards instead
- Add explicit return types to exported functions (faster declaration emit)
- Untyped package?
declare module 'pkg' { const v: unknown; export default v; } in types/ambient.d.ts
Validation
Zod (TypeScript inference) or TypeBox (Fastify native). Validate at boundaries only: request entry, before DB ops, env vars at startup. Use .extend(), .pick(), .omit(), .partial(), .merge() for DRY schemas.
Error Handling
Custom error hierarchy: AppError(message, statusCode, isOperational) → ValidationError(400), NotFoundError(404), UnauthorizedError(401), ForbiddenError(403), ConflictError(409)
Centralized handler middleware:
AppError → return { error: message } with statusCode
- Unknown → log full stack, return 500 + generic message in production
- Async wrapper:
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
Codes: 400 bad input | 401 no auth | 403 no permission | 404 missing | 409 conflict | 422 business rule | 429 rate limited | 500 server fault
API Design
- Resources: plural nouns (
/users), max 2 nesting levels (/users/:id/orders)
- Methods: GET read | POST create | PUT replace | PATCH partial | DELETE remove
- Versioning: URL path
/api/v1/
- Response:
{ data, pagination?: { page, limit, total, totalPages } }
- Errors:
{ error: { code, message, details? } }
- Queries:
?page=1&limit=20&status=active&sort=createdAt,desc
- Return
Location header on 201. Use 204 for successful DELETE with no body.
Async Patterns
| Pattern |
Use When |
async/await |
Sequential operations |
Promise.all |
Parallel independent ops |
Promise.allSettled |
Parallel, some may fail |
Promise.race |
Timeout or first-wins |
Never readFileSync / sync methods in production. Offload CPU work to worker threads. Stream large payloads.
Discipline
- For non-trivial changes, pause and ask: "is there a more elegant way?" Skip for obvious fixes.
- Simplicity first — every change as simple as possible, impact minimal code
- Only touch what's necessary — avoid introducing unrelated changes
- No hacky workarounds — if a fix feels wrong, step back and implement the clean solution
References
- TypeScript config — tsconfig, ESM, branded types, compiler performance
- Security — JWT, password hashing, rate limiting, OWASP
- Database & production — connection pooling, transactions, Docker, logging
1---2name: nodejs-backend-23description: Node.js backend patterns: framework selection, layered architecture, TypeScript, validation, error handling, security, production deployment. Use when building REST APIs, Express/Fastify servers, microservices, or server-side TypeScript.4---56# Node.js Backend78## Framework Selection910| Context | Choose | Why |11|---------|--------|-----|12| Edge/Serverless | Hono | Zero-dep, fastest cold starts |13| Performance API | Fastify | 2-3x faster than Express, built-in schema validation |14| Enterprise/team | NestJS | DI, decorators, structured conventions |15| Legacy/ecosystem | Express | Most middleware, widest adoption |1617Ask user: deployment target, cold start needs, team experience, existing codebase.1819## Architecture2021```22src/23├── routes/ # HTTP: parse request, call service, format response24├── middleware/ # Auth, validation, rate limiting, logging25├── services/ # Business logic (no HTTP types)26├── repositories/ # Data access only (queries, ORM)27├── config/ # Env, DB pool, constants28└── types/ # Shared TypeScript interfaces29```3031- Routes never contain business logic32- Services never import Request/Response33- Repositories never throw HTTP errors34- For scripts/prototypes: single file is fine — ask "will this grow?"3536## TypeScript Rules3738- Use `import type { }` for type-only imports — eliminates runtime overhead39- Prefer `interface` for object shapes (2-5x faster type resolution than intersections)40- Prefer `unknown` over `any` — forces explicit narrowing41- Use `z.infer<typeof Schema>` as single source of truth — never duplicate types and schemas42- Minimize `as` assertions — use type guards instead43- Add explicit return types to exported functions (faster declaration emit)44- Untyped package? `declare module 'pkg' { const v: unknown; export default v; }` in `types/ambient.d.ts`4546## Validation4748**Zod** (TypeScript inference) or **TypeBox** (Fastify native). Validate at boundaries only: request entry, before DB ops, env vars at startup. Use `.extend()`, `.pick()`, `.omit()`, `.partial()`, `.merge()` for DRY schemas.4950## Error Handling5152Custom error hierarchy: `AppError(message, statusCode, isOperational)` → `ValidationError(400)`, `NotFoundError(404)`, `UnauthorizedError(401)`, `ForbiddenError(403)`, `ConflictError(409)`5354Centralized handler middleware:55- `AppError` → return `{ error: message }` with statusCode56- Unknown → log full stack, return 500 + generic message in production57- Async wrapper: `const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);`5859Codes: 400 bad input | 401 no auth | 403 no permission | 404 missing | 409 conflict | 422 business rule | 429 rate limited | 500 server fault6061## API Design6263- **Resources**: plural nouns (`/users`), max 2 nesting levels (`/users/:id/orders`)64- **Methods**: GET read | POST create | PUT replace | PATCH partial | DELETE remove65- **Versioning**: URL path `/api/v1/`66- **Response**: `{ data, pagination?: { page, limit, total, totalPages } }`67- **Errors**: `{ error: { code, message, details? } }`68- **Queries**: `?page=1&limit=20&status=active&sort=createdAt,desc`69- Return `Location` header on 201. Use 204 for successful DELETE with no body.7071## Async Patterns7273| Pattern | Use When |74|---------|----------|75| `async/await` | Sequential operations |76| `Promise.all` | Parallel independent ops |77| `Promise.allSettled` | Parallel, some may fail |78| `Promise.race` | Timeout or first-wins |7980Never `readFileSync` / sync methods in production. Offload CPU work to worker threads. Stream large payloads.8182## Discipline8384- For non-trivial changes, pause and ask: "is there a more elegant way?" Skip for obvious fixes.85- Simplicity first — every change as simple as possible, impact minimal code86- Only touch what's necessary — avoid introducing unrelated changes87- No hacky workarounds — if a fix feels wrong, step back and implement the clean solution8889## References9091- [TypeScript config](references/typescript-config.md) — tsconfig, ESM, branded types, compiler performance92- [Security](references/security.md) — JWT, password hashing, rate limiting, OWASP93- [Database & production](references/database-production.md) — connection pooling, transactions, Docker, logging