TK
All writing
Backend

Express.js patterns that survive past the first 10 routes

Express gives you almost no structure, which is why most Express codebases rot. The conventions I impose from day one: a layered architecture, async error handling that actually works, typed middleware, and centralised errors.

Tanveer Khan9 min read

Express is minimal by design — it hands you routing and middleware and gets out of the way. That freedom is exactly why Express apps tend to rot: business logic in route handlers, error handling that silently swallows async throws, and middleware nobody can type. Here are the conventions I impose from the first commit to keep an Express + TypeScript service maintainable.

Layer it: route → controller → service

The discipline that matters most: a route wires a path to a controller, a controller translates HTTP to a service call, and a service holds the business logic (and is the only layer that knows about the database). Keep handlers thin.

routes/bookings.ts
ts
// routes/bookings.ts — wiring only
import { Router } from "express";
import * as controller from "../controllers/bookings";

const router = Router();
router.post("/", controller.create);
export default router;
controllers/bookings.ts
ts
// controllers/bookings.ts — HTTP in, HTTP out
export const create: RequestHandler = async (req, res) => {
  const input = bookingSchema.parse(req.body); // zod validation
  const booking = await bookingService.create(req.user!, input);
  res.status(201).json(booking);
};

Fix async error handling — the classic Express footgun

Express 4 does NOT catch errors thrown in an async handler. A rejected promise becomes an unhandled rejection and the request hangs. The robust fix is a wrapper that forwards async errors to next() — or upgrade to Express 5, which handles returned rejected promises natively.

asyncHandler.ts
ts
// asyncHandler.ts — wrap every async handler so throws reach the error mw
import { RequestHandler } from "express";

export const asyncHandler =
  (fn: RequestHandler): RequestHandler =>
  (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

// usage
router.post("/", asyncHandler(controller.create));

Forgetting this is the #1 Express production bug: a handler throws, nothing catches it, the client's request hangs until it times out. Either wrap every async handler or move to Express 5 — do not rely on remembering to try/catch in each one.

Centralise error handling

One error-handling middleware, registered last, turns any thrown error into a consistent JSON response. Pair it with a typed AppError so services can throw meaningful, status-carrying errors.

errorHandler.ts
ts
export class AppError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

// the LAST middleware — four args is what marks it as an error handler
import { ErrorRequestHandler } from "express";

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: err.message });
  }
  if (err instanceof ZodError) {
    return res.status(422).json({ errors: err.flatten().fieldErrors });
  }
  console.error(err); // unexpected — log it, don't leak it
  res.status(500).json({ error: "Internal server error" });
};

Type your middleware and the request

Augment Express's Request type so req.user (set by auth middleware) is typed everywhere downstream, instead of casting in every handler.

types/express.d.ts
ts
// types/express.d.ts
import "express";
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: "admin" | "member" };
    }
  }
}

Express will never impose structure for you — that is the whole deal. Impose it yourself from the start: a layered architecture, an async wrapper so errors can't vanish, one central error handler, and a typed request. Those four conventions are the difference between an Express app that scales and one that becomes a pile of fragile route handlers.