Node.js backend practices

Guide agents to organize routes, middleware, and domain layers on Express/Fastify/Nest-style stacks, with unified errors, logging, and configuration injection.

This page gives Agents a complete Node.js backend reference: a full Express middleware chain example (auth/logging/rate-limiting/error handling), a unified error middleware, Zod request validation, type-safe environment variable parsing, and graceful shutdown implementation.

The SKILL defines middleware order, auth, and Zod validation failure response format; prohibits logging sensitive fields like password/token; wraps async routes with asyncHandler to prevent unhandled Promise rejections.

  • Database: connection pools, transaction boundaries, and how you reference migrations (Prisma/Knex).
  • Observability: request id, structured logging (pino), aligned with the observability skill.
  • Testing: supertest integration tests + conventions for mocking outbound HTTP (msw/nock).

Request pipeline (middleware → routes)

  [ TCP / TLS termination (often at reverse proxy) ]
        │
        ▼
  ┌─────────────┐     Trust proxy, request id, security headers, CORS, body size / rate limits
  │Global middleware│──── Parse cookie/body; do not trust unvalidated bodies before auth
  └─────────────┘
        │
        ▼
  ┌─────────────┐     JWT / session / API key → inject user / tenant context
  │ Auth middleware │──── Failure: uniform 401/403 without leaking internals
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Route params + DTO/schema validation; failure: 422/400 with field errors
  │Validate & route │──── Handlers receive validated input only
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Domain services / repos; throw mappable business error types
  │ Domain & I/O  │──── Uncaught → outer error middleware → 5xx + logging
  └─────────────┘
        │
        ▼
  [ Response serialization ] ──► 404 / global error handler (stack traces dev-only)

Framework names differ (Express chains, Fastify hooks, Nest pipes/guards/interceptors), but the pattern holds: generic work near the edge, auth after body parsing and before business logic, centralized errors last.

Express middleware chain and Zod validation

Full Express middleware chain (with order comments):

// app.ts — Express full middleware configuration
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import pinoHttp from 'pino-http'
import { authenticate } from './middleware/auth'
import { errorHandler } from './middleware/errorHandler'
import { itemsRouter } from './routes/items'

const app = express()

// 1. Trust proxy (required behind reverse proxy)
app.set('trust proxy', 1)

// 2. Security headers (set early)
app.use(helmet())

// 3. CORS (preflight completes before body parsing)
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))

// 4. Request logging + Request ID injection
app.use(pinoHttp({
  genReqId: (req) => req.headers['x-request-id'] ?? crypto.randomUUID(),
  redact: ['req.headers.authorization', 'req.body.password'],
}))

// 5. Basic rate limiting (before body parsing)
app.use(rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true }))

// 6. Body parsing (size-limited)
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true, limit: '1mb' }))

// 7. Authentication (after body parsing, before routes)
app.use('/api', authenticate)

// 8. Business routes
app.use('/api/v1/items', itemsRouter)

// 9. 404 handler
app.use((_req, res) => res.status(404).json({ type: 'not-found', status: 404 }))

// 10. Global error handler (must be last, 4 parameters)
app.use(errorHandler)

Zod request validation middleware:

// middleware/validate.ts
import { z, ZodSchema } from 'zod'
import { Request, Response, NextFunction } from 'express'

export function validate<T>(schema: ZodSchema<T>, target: 'body' | 'query' | 'params' = 'body') {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[target])
    if (!result.success) {
      return res.status(422).json({
        type: 'https://api.example.com/problems/validation-error',
        title: 'Validation Error',
        status: 422,
        errors: result.error.issues.map(issue => ({
          field: issue.path.join('.'),
          code: issue.code,
          message: issue.message,
        })),
      })
    }
    req[target] = result.data  // replace with validated data
    next()
  }
}

// Usage example
const CreateItemSchema = z.object({
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  sku: z.string().regex(/^[A-Z]{3}-\d{3}$/),
})

router.post('/', validate(CreateItemSchema), async (req, res, next) => {
  // req.body is now CreateItem type, validated by Zod
})

Unified error handling, env vars, and graceful shutdown

Unified error handling middleware:

// middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'

export class AppError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    message: string,
    public readonly isOperational = true
  ) { super(message) }
}

// asyncHandler wrapper (avoids try/catch in every route)
export const asyncHandler = (fn: Function) =>
  (req: Request, res: Response, next: NextFunction) =>
    Promise.resolve(fn(req, res, next)).catch(next)

// Centralized error handler (must have 4 parameters)
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
  const isProd = process.env.NODE_ENV === 'production'

  if (err instanceof AppError && err.isOperational) {
    // Known business error → 4xx
    return res.status(err.status).json({
      type: `https://api.example.com/problems/${err.code}`,
      title: err.message,
      status: err.status,
      requestId: (req as any).id,
    })
  }

  // Unknown error → 5xx (hide details in production)
  req.log?.error({ err, reqId: (req as any).id }, 'Unhandled error')
  res.status(500).json({
    type: 'https://api.example.com/problems/internal-error',
    title: 'Internal Server Error',
    status: 500,
    ...(isProd ? {} : { detail: err.message, stack: err.stack }),
  })
}

Type-safe environment variable parsing (Zod):

// config/env.ts
import { z } from 'zod'

const EnvSchema = z.object({
  NODE_ENV:    z.enum(['development', 'production', 'test']).default('development'),
  PORT:        z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET:  z.string().min(32),
  REDIS_URL:   z.string().url().optional(),
  ALLOWED_ORIGINS: z.string().optional(),
})

const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
  console.error('Invalid environment variables:', parsed.error.format())
  process.exit(1)
}

export const env = parsed.data

Graceful Shutdown:

// server.ts
const server = app.listen(env.PORT, () => {
  console.log(`Server listening on port ${env.PORT}`)
})

async function gracefulShutdown(signal: string) {
  console.log(`${signal} received. Starting graceful shutdown...`)

  // 1. Stop accepting new connections
  server.close(async (err) => {
    if (err) console.error('Error closing server:', err)

    // 2. Close database connection pool
    await db.$disconnect()

    // 3. Close Redis connection
    await redis.quit()

    console.log('Graceful shutdown complete')
    process.exit(err ? 1 : 0)
  })

  // 4. Force exit on timeout (avoid hanging)
  setTimeout(() => {
    console.error('Shutdown timeout, forcing exit')
    process.exit(1)
  }, 30_000)
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT',  () => gracefulShutdown('SIGINT'))

Align with the observability skill: the same request id should flow through access logs, business logs, and error reports to trace 4xx/5xx end to end.

Middleware order note generator

Check the capabilities your service actually uses to generate a numbered snippet you can paste into the SKILL “middleware order” section; ordering follows common Express/Fastify guidance—adjust if your stack differs.

Included middleware / capabilities

With nothing checked, a placeholder is emitted; select all for the full reference chain. Nest users can map entries to Middleware → Guard → Pipe → Interceptor → Filter.

---
name: node-backend-practices
description: Layered Node HTTP service with a clear error model
---
# Rules
- Middleware order: trust proxy → helmet → CORS → logger+reqId → rate limit → body parse → auth → routes → 404 → errorHandler
- Async routes must use asyncHandler wrapper, or async/await + try/catch + next(err)
- Error tiers: AppError (business/4xx) vs unknown errors (5xx), unified mapping in errorHandler
- Request validation: Zod safeParse, 422 response includes errors array (field/code/message)
- Env vars: Zod EnvSchema validated at startup; missing/malformed fields call process.exit(1)
- Logging: pino structured logging, redact password/token/Authorization
- Graceful shutdown: SIGTERM → server.close() → db.$disconnect() → 30s timeout force exit

# Steps
1. Create config/env.ts: Zod parses process.env, exports env object
2. Create middleware/errorHandler.ts: AppError class + asyncHandler + errorHandler
3. Create app.ts: mount middleware chain in order
4. Use validate(Schema) middleware in routes to validate body/query/params
5. Business logic throws new AppError(status, code, message)
6. In server.ts listen for SIGTERM/SIGINT, implement gracefulShutdown
7. Integration tests: supertest(app) + beforeAll/afterAll manage DB connection

Back to skills More skills