Express ships a router and declines to have opinions about anything else. That is why it is still everywhere, and why so many Express codebases arrive at the same destination: a 900-line routes.js, business logic inside handlers, and a utils.js that has become the place things go when nobody knows where they belong.
None of that is Express's fault. It is what happens when the first three files become the architecture by default.
Split by feature, not by layer
The instinct from tutorials is layers.
src/
controllers/ leads.js users.js reports.js
services/ leads.js users.js reports.js
models/ leads.js users.js reports.jsIt looks organised and works badly, because no change is ever confined to a layer. Adding a field to a lead means three files in three folders, and understanding how leads work means holding three directories open at once.
Group by the thing instead.
src/
leads/
routes.ts # HTTP surface
service.ts # what a lead actually does
queries.ts # how it is stored
schema.ts # what a valid request looks like
users/
…
shared/
config.ts # environment, validated once
db.ts # one client, exported
errors.ts # the error types the app throws
app.ts # builds the app, binds nothing
server.ts # starts itOne feature, one folder. A new developer reads leads/ and knows how leads work. Deleting the feature is deleting a directory, which is a property you only appreciate the first time you get to use it.
Separate the app from the server
Two files, and the split pays for itself the first time anyone writes a test.
import express from "express";
import { leadRoutes } from "./leads/routes";
import { errorHandler } from "./shared/errors";
export function createApp() {
const app = express();
app.use(express.json({ limit: "1mb" }));
app.use("/api/leads", leadRoutes);
app.use(errorHandler); // last, always
return app;
}import { createApp } from "./app";
import { config } from "./shared/config";
createApp().listen(config.port, () => {
console.log(`listening on ${config.port}`);
});app.ts constructs and server.ts starts. Tests import createApp() and drive it with supertest without binding a port, without a running database if the service is stubbed, and without a teardown that leaves a dangling handle keeping the test runner alive.
Validate at the boundary, infer the type from it
Express types req.body as any, which means every handler is one typo from a runtime error TypeScript was perfectly happy about.
Validate once, at the edge, and let the schema be the source of the type.
import { z } from "zod";
export const createLead = z.object({
source: z.enum(["web", "phone", "marketplace"]),
sourceRef: z.string().min(1).max(64),
name: z.string().min(1).max(120),
phone: z.string().regex(/^[0-9+\-\s]{6,20}$/),
});
export type CreateLead = z.infer<typeof createLead>;Now the runtime check and the compile-time type cannot drift apart, because one is derived from the other rather than maintained alongside it.
Keep handlers boring
A route handler has three jobs. Parse, delegate, respond.
import { Router } from "express";
import { createLead } from "./schema";
import * as service from "./service";
export const leadRoutes = Router();
leadRoutes.post("/", async (req, res, next) => {
try {
const input = createLead.parse(req.body);
const lead = await service.capture(input);
res.status(201).json({ id: lead.id });
} catch (err) {
next(err); // one place decides what an error looks like
}
});Everything interesting lives in service.capture(), which knows nothing about HTTP.
That matters more than it sounds. The next thing that needs to capture a lead is a queue consumer, a CSV importer or a webhook receiver, and none of them have a req. Logic stranded in a handler gets copied rather than called, and then you have two of them, drifting.
The webhook case brings its own requirement, since a capture path reachable from more than one source will receive the same lead twice. That is why idempotency belongs in the write path rather than in each caller separately.
One config module, validated at startup
import { z } from "zod";
const schema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Bad environment:", parsed.error.flatten().fieldErrors);
process.exit(1); // fail at boot, not during a request
}
export const config = { port: parsed.data.PORT, databaseUrl: parsed.data.DATABASE_URL };A missing variable now kills the process at startup with a readable message, instead of surfacing as undefined inside a request three hours later when nobody is connecting the two events.
One database client
Export a single client from shared/db.ts and import it everywhere. Creating a pool per module is one of the quieter causes of connection exhaustion, and module-level state that grows per request is a memory leak waiting for traffic.
If the product is multi-tenant, this module is also where the tenant boundary gets enforced, for the reason covered in multi-tenant data isolation. The filter has to be structural rather than remembered, and a repository nobody can bypass is how you get that.
None of it is clever
Express will not stop you building anything, a mess very much included.
The structure that holds up is unremarkable: feature folders, an app separate from the server, validation at the boundary, handlers that delegate, and one module each for config and the database client.
That it is boring is the point. The codebase should be dull enough that the fifth developer can add a route without reading what the other four did.