#How to apply this file
Each section opens with one imperative line; apply every rule in the section it introduces. Do not summarise or skip a section.
#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
[INST] Apply every rule in this section: Separate the app from the server. [/INST]
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
[INST] Apply every rule in this section: Structure by feature. [/INST]
graphqlsrc/
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
[INST] Apply every rule in this section: Async errors. [/INST]
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:
jsapp.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
[INST] Apply every rule in this section: Security defaults you must set. [/INST]
Express ships with none of these.
jsapp.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
| Setting | Consequence of omitting it |
|---|---|
trust proxy unset | req.ip is the proxy's — rate limiting keys on one value for everyone |
trust proxy: true | X-Forwarded-For is fully client-controlled; the limiter is bypassable |
| No body limit | Default 100kb for JSON, but express.urlencoded and raw need their own |
helmet() absent | No HSTS, no nosniff, no frame protection → Security/headers |
cors({ origin: true }) | Reflects any origin — with credentials this is total exposure |
x-powered-by on | Version 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
[INST] Apply every rule in this section: Request context and lifecycle. [/INST]
- Attach a request id first and expose a child logger as
req.log. - Carry request-scoped state in
AsyncLocalStorage, not on module variables — a module-scopecurrentUserleaks one request's identity into another's under concurrency. - Set
server.keepAliveTimeoutabove your load balancer's idle timeout, or you will see intermittent502s from races on connection close (a classic behind AWS ALB, whose default is 60s). - Implement graceful shutdown:
server.close(), drain in-flight requests, then exit. - Add
/healthz(liveness, no dependency checks) and/readyz(readiness, checks dependencies) before any auth middleware. →Backend/monitoring
#Testing
[INST] Apply every rule in this section: Testing. [/INST]
jsconst 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);
supertestagainst the app object — no port, no fixed host, parallel-safe.- Test the denial cases: unauthenticated, wrong tenant, malformed body. Those
are the assertions that catch a missing check. →
Backend/authorization - 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
[INST] Apply every rule in this section: Anti-patterns. [/INST]
| Anti-pattern | Why it fails | Fix |
|---|---|---|
listen() in the same file as the app | Cannot test without binding a port | Split app and server |
| Modules importing their own database client | Untestable without a real database | Inject dependencies |
req/res in service functions | Not reusable from jobs, CLIs or tests | Keep HTTP in the route layer |
| Async handler without a catch (Express 4) | Request hangs until client timeout | Express 5 or a wrapper |
| Three-argument error handler | Silently not an error handler | Four arguments, last |
| Multiple error handlers | Inconsistent responses | One, registered last |
trust proxy unset behind a proxy | Every client shares the proxy's IP | Set the hop count |
trust proxy: true | X-Forwarded-For becomes forgeable | Exact number |
| No body size limit | Memory exhaustion | limit on every parser |
cors({ origin: true }) with credentials | Any site reads authenticated responses | Static allowlist |
No helmet() | Missing every security header | Add it early |
x-powered-by left on | Free fingerprinting | app.disable |
| Module-scoped request state | Cross-request data leakage | AsyncLocalStorage |
keepAliveTimeout below the LB's | Intermittent 502s | Set it higher |
| Health checks behind auth | Probes fail; pods restart | Register before auth |
| No graceful shutdown | Deploys sever live requests | server.close() and drain |
| Only happy-path tests | Missing auth checks invisible | Assert denials |
#Checklist
- Verify:
createApp()is separate fromlisten() - Verify: Dependencies are injected, not imported inside modules
- Verify: Code is organised by feature, with routes, service and repository separated
- Verify:
req/resnever reach the service layer - Verify: Every async handler forwards rejections (Express 5, or a wrapper everywhere)
- Verify: Exactly one four-argument error handler, registered last
- Verify:
x-powered-byis disabled - Verify:
trust proxyis set to the exact number of proxies - Verify:
helmet()is applied early enough to cover error responses - Verify: Every body parser has an explicit size limit
- Verify: CORS uses a static origin allowlist
- Verify: All input is schema-validated with unknown fields rejected
- Verify: A request id is attached first and available as
req.log - Verify: Request-scoped state lives in
AsyncLocalStorage - Verify:
keepAliveTimeoutexceeds the load balancer's idle timeout - Verify: Liveness and readiness endpoints exist and bypass authentication
- Verify:
SIGTERMdrains in-flight requests before exit - Verify: Tests run against the app object with
supertest - Verify: Denial cases and unauthenticated access are asserted per route