Claude Fable 5.1 & GPT-6 Astra packages are live

Express

Free

Express application structure — router organisation, async error handling, security defaults, request context, and testing without a live server.

222 lines8.6 KB Glm Backend
targetModels
GLM-5.3GLM-5.2GLM-5 FamilyGLM-4.6Future GLM Models
name
express
category
Backend
description
Express application structure — router organisation, async error handling, security defaults, request context, and testing without a live server.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for GLM: scripts/model-profiles.json -->

#Task boundary

  1. Implement only what the task names; no extra abstractions or files.
  2. English-only comments and identifiers.
  3. Stop when the checklist passes.

#Purpose

Rules for structuring an Express application. Express is unopinionated, which means every decision it does not make for you is one you must make deliberately — and the defaults it does ship are tuned for 2012.

Runtime concerns are Backend/node; middleware ordering is Backend/middlewares.


#Separate the app from the server

js
// app.js — builds and returns the app. No listen(), no side effects.
export function createApp({ db, mailer, logger }) {
  const app = express();
  app.use(/* … */);
  app.use("/v1", routes({ db, mailer }));
  return app;
}

// server.js — the only file that binds a port
const server = createApp({ db, mailer, logger }).listen(PORT);

This one split makes integration testing possible without a live port (supertest(createApp({ db: testDb }))), lets tests run in parallel, and keeps dependency wiring in one place. → Testing/integration

Pass dependencies in. A module that imports its own database client cannot be tested without one.


#Structure by feature

graphql
src/
  features/
    orders/
      orders.routes.js     # HTTP: parse, validate, call the service, format
      orders.service.js    # business rules — no req/res anywhere
      orders.repo.js       # data access, tenant-scoped
      orders.schema.js     # zod schemas
      orders.test.js
  middleware/
  lib/

The rule that matters: req and res do not leave the route layer. A service that takes req cannot be called from a background job, a CLI, or a test without constructing a fake request.

Routers compose — mount feature routers on the app rather than declaring a hundred routes in one file.


#Async errors

js
// Express 4: a rejected promise is NOT caught. The request hangs until timeout.
app.get("/orders/:id", async (req, res) => { throw new Error("boom"); });  // ← hangs

// Fix 1 — Express 5 forwards rejections automatically. Prefer this.
// Fix 2 — Express 4: a wrapper
const ah = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/orders/:id", ah(async (req, res) => { … }));

This is the single most common Express bug: a route that throws asynchronously never responds, the client times out, and nothing appears in the logs. Either upgrade to Express 5, or apply the wrapper to every async handler — one missed route is one hanging endpoint.

The error handler is identified by arity, not by position alone:

js
app.use((err, req, res, next) => { … });   // four arguments, registered last

A three-argument function registered last is not an error handler and will be skipped silently. → Backend/error-handling


#Security defaults you must set

Express ships with none of these.

js
app.disable("x-powered-by");                       // stop advertising the stack
app.set("trust proxy", 1);                         // exact hop count, never `true`
app.use(helmet());                                 // security headers
app.use(express.json({ limit: "100kb" }));         // bounded body
app.use(cors({ origin: ALLOWED, credentials: true }));   // static allowlist
SettingConsequence of omitting it
trust proxy unsetreq.ip is the proxy's — rate limiting keys on one value for everyone
trust proxy: trueX-Forwarded-For is fully client-controlled; the limiter is bypassable
No body limitDefault 100kb for JSON, but express.urlencoded and raw need their own
helmet() absentNo HSTS, no nosniff, no frame protection → Security/headers
cors({ origin: true })Reflects any origin — with credentials this is total exposure
x-powered-by onVersion fingerprinting for attackers

Set trust proxy to the number of proxies in front of you. true means trust whatever the client sent. → API/rate-limiting

Validate every input at the boundary with a schema, and reject unknown fields. → Backend/validation


#Request context and lifecycle

  1. Attach a request id first and expose a child logger as req.log.
  2. Carry request-scoped state in AsyncLocalStorage, not on module variables — a module-scope currentUser leaks one request's identity into another's under concurrency.
  3. Set server.keepAliveTimeout above your load balancer's idle timeout, or you will see intermittent 502s from races on connection close (a classic behind AWS ALB, whose default is 60s).
  4. Implement graceful shutdown: server.close(), drain in-flight requests, then exit.
  5. Add /healthz (liveness, no dependency checks) and /readyz (readiness, checks dependencies) before any auth middleware. → Backend/monitoring

#Testing

js
const res = await request(createApp({ db: testDb }))
  .post("/v1/orders")
  .set("Cookie", sessionFor(user))
  .send({ items: [{ sku: "ABC-1", qty: 2 }] });
expect(res.status).toBe(201);
  1. supertest against the app object — no port, no fixed host, parallel-safe.
  2. Test the denial cases: unauthenticated, wrong tenant, malformed body. Those are the assertions that catch a missing check. → Backend/authorization
  3. One test that enumerates every registered route and asserts an unauthenticated request is rejected, with an explicit public allowlist, catches the endpoint somebody forgot to protect.

#Anti-patterns

Anti-patternWhy it failsFix
listen() in the same file as the appCannot test without binding a portSplit app and server
Modules importing their own database clientUntestable without a real databaseInject dependencies
req/res in service functionsNot reusable from jobs, CLIs or testsKeep HTTP in the route layer
Async handler without a catch (Express 4)Request hangs until client timeoutExpress 5 or a wrapper
Three-argument error handlerSilently not an error handlerFour arguments, last
Multiple error handlersInconsistent responsesOne, registered last
trust proxy unset behind a proxyEvery client shares the proxy's IPSet the hop count
trust proxy: trueX-Forwarded-For becomes forgeableExact number
No body size limitMemory exhaustionlimit on every parser
cors({ origin: true }) with credentialsAny site reads authenticated responsesStatic allowlist
No helmet()Missing every security headerAdd it early
x-powered-by left onFree fingerprintingapp.disable
Module-scoped request stateCross-request data leakageAsyncLocalStorage
keepAliveTimeout below the LB'sIntermittent 502sSet it higher
Health checks behind authProbes fail; pods restartRegister before auth
No graceful shutdownDeploys sever live requestsserver.close() and drain
Only happy-path testsMissing auth checks invisibleAssert denials

#Checklist

  • createApp() is separate from listen()
  • Dependencies are injected, not imported inside modules
  • Code is organised by feature, with routes, service and repository separated
  • req/res never reach the service layer
  • Every async handler forwards rejections (Express 5, or a wrapper everywhere)
  • Exactly one four-argument error handler, registered last
  • x-powered-by is disabled
  • trust proxy is set to the exact number of proxies
  • helmet() is applied early enough to cover error responses
  • Every body parser has an explicit size limit
  • CORS uses a static origin allowlist
  • All input is schema-validated with unknown fields rejected
  • A request id is attached first and available as req.log
  • Request-scoped state lives in AsyncLocalStorage
  • keepAliveTimeout exceeds the load balancer's idle timeout
  • Liveness and readiness endpoints exist and bypass authentication
  • SIGTERM drains in-flight requests before exit
  • Tests run against the app object with supertest
  • Denial cases and unauthenticated access are asserted per route