Field note / javascript

publishedupdated 2026-05-04#nodejs#express#api#middleware#validation

Express API Design in Production

How I structure Express services for real product APIs: middleware boundaries, validation, error handling, idempotency, and operational safety.

TL;DR

  • Express stays relevant because it is flexible, boring, and easy to compose around real production needs.
  • The hard part is not route registration; it is middleware boundaries, validation, auth, idempotency, and failure handling.
  • I keep business rules out of ad hoc middleware chains and I make request contracts explicit early.
  • Good Express APIs feel small at the edge and disciplined underneath.

Context

I have used Node.js and Express-style services in environments where the request path was not the only concern. The real production questions were:

  • how do we validate and reject bad input early?
  • where do auth and tenant context get attached?
  • how do we keep async errors predictable?
  • how do we make retries safe for writes?
  • how do we keep routers modular without building a maze?

That is where API quality is won or lost.

The Approach

1. Treat middleware as boundary plumbing, not a dumping ground

Good middleware responsibilities:

  • request parsing
  • auth/session extraction
  • logging and correlation ids
  • validation
  • rate limiting
  • response shaping

Bad middleware responsibilities:

  • hidden business decisions
  • cross-route orchestration that should live in services
  • mutating random request shape without a contract
import express from 'express'
import { z } from 'zod'

const app = express()
app.use(express.json({ limit: '1mb' }))

const createOrderSchema = z.object({
  sku: z.string().min(1),
  quantity: z.number().int().positive(),
})

function validateBody<T>(schema: z.ZodSchema<T>) {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const result = schema.safeParse(req.body)

    if (!result.success) {
      return res.status(400).json({ error: 'Invalid request', issues: result.error.flatten() })
    }

    req.body = result.data
    next()
  }
}

app.post('/orders', validateBody(createOrderSchema), async (req, res, next) => {
  try {
    const order = await createOrder(req.body)
    res.status(201).json(order)
  } catch (error) {
    next(error)
  }
})

2. Make write retries safe with idempotency

This matters for:

  • checkout
  • payments
  • order placement
  • provisioning APIs
  • anything users may submit twice after a timeout
app.post('/orders', async (req, res, next) => {
  try {
    const key = req.header('Idempotency-Key')
    if (!key) {
      return res.status(400).json({ error: 'Missing Idempotency-Key header' })
    }

    const existing = await idempotencyStore.get(key)
    if (existing) {
      return res.status(existing.statusCode).json(existing.body)
    }

    const order = await createOrder(req.body)
    const responseBody = { id: order.id, status: 'created' }

    await idempotencyStore.set(key, {
      statusCode: 201,
      body: responseBody,
    })

    return res.status(201).json(responseBody)
  } catch (error) {
    next(error)
  }
})

3. Centralize async error handling

Express still rewards discipline here.

app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message
  res.status(500).json({ error: message })
})

I want route handlers to be readable, not each one inventing its own error envelope.

4. Keep routers modular and service logic separate

const ordersRouter = express.Router()

ordersRouter.get('/:orderId', async (req, res, next) => {
  try {
    const order = await orderService.findById(req.params.orderId)
    if (!order) return res.status(404).json({ error: 'Not found' })
    res.json(order)
  } catch (error) {
    next(error)
  }
})

app.use('/orders', ordersRouter)

The router owns the HTTP contract. The service owns business rules.

5. Add observability as part of the contract

At minimum, I want:

  • request id
  • route + method
  • latency
  • status code
  • principal or tenant where safe

Because API regressions become expensive fast when the team cannot see where latency or error rates cluster.

Trade-offs

  • Express is highly flexible, but that flexibility makes architecture discipline your responsibility.
  • Middleware chains are powerful, but too many implicit side effects make APIs hard to reason about.
  • The ecosystem is mature, but large teams can accumulate inconsistency unless they standardize validation, error envelopes, and observability.
  • Newer frameworks may provide stronger built-in types or Web-standard primitives, but Express still wins on familiarity and ecosystem gravity in many teams.

References