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 — 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 — 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 — 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.
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
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.