Express is unopinionated, which sounds like a feature until you inherit a 40-route file with prisma.user.findMany() interleaved with bcrypt.hash() interleaved with res.json(). Nothing is technically wrong. Everything is impossible to change.
Express gives you a router and a middleware chain. That's it, on purpose. Architecture is your problem — and the longer you put it off, the harder it gets to retrofit. This piece is the architecture I keep landing on after the third refactor of a service.
Express 4 vs Express 5 (Quick Note)
Express 5 went stable in October 2024. The headline change for everyday code: async errors propagate. In Express 4, a thrown error inside an async handler that wasn't awaited correctly would silently hang the request. In 5, rejected promises returned from a handler reach the error middleware automatically — no more express-async-errors shim, no more wrapping every route in a try/catch boilerplate.
If you're starting fresh, use 5. If you're on 4, it's still ubiquitous and supported, but plan the upgrade — most apps need only a handful of edits.
Thin Controllers, Real Services
The single highest-leverage rule: controllers know about HTTP, services know about your domain. If a service file imports req or res, something has slipped.
import { Router } from 'express';
import { z } from 'zod';
import { usersService } from './users.service';
const router = Router();
const Create = z.object({ email: z.string().email(), name: z.string() });
router.post('/', async (req, res) => {
const input = Create.parse(req.body);
const user = await usersService.create(input, { actorId: req.user.id });
res.status(201).json(user);
});
export { router as usersRouter };
import { Conflict } from '../errors';
import { usersRepo } from './users.repo';
export const usersService = {
async create(input: { email: string; name: string }, ctx: { actorId: string }) {
const existing = await usersRepo.findByEmail(input.email);
if (existing) throw new Conflict('email already taken');
return usersRepo.insert(input);
},
};
The controller is five real lines. The service has the rule (no duplicate emails) and the repository has the SQL/ORM call. You can unit-test the service without spinning up Express, and you can swap Postgres for SQLite in tests by stubbing the repo.
Where Middleware Should And Shouldn't Live
Middleware is great for cross-cutting concerns that every request shares: request ID, body parsing, logging, auth, rate limiting, helmet headers, CORS. It is bad for business rules. The smell test: would two unrelated routes both want this exact behavior? If yes, middleware. If no, it's a service call inside the handler.
The order matters and is easy to get wrong. A safe default:
const app = express();
app.use(helmet());
app.use(cors({ origin: env.ALLOWED_ORIGINS }));
app.use(express.json({ limit: '100kb' }));
app.use(requestId); // adds req.id, req.log
app.use(httpLogger); // pino-http child of req.log
app.use('/api', authMiddleware); // protected routes only below this line
app.use('/api/users', usersRouter);
app.use('/api/orders', ordersRouter);
app.use(notFoundHandler); // 404
app.use(errorHandler); // 500 — must be last, must take 4 args
Two non-obvious gotchas. Body parser before auth — auth often needs to read the body (e.g. webhook signature). Error handler must take exactly four parameters (err, req, res, next); Express identifies it by arity, not by name.
Repository Pattern, Without The Java
You don't need an interface, an abstract class, and a factory. You need a single file per aggregate that hides the ORM.
import { prisma } from '../db';
export const usersRepo = {
findByEmail: (email: string) =>
prisma.user.findUnique({ where: { email } }),
insert: (data: { email: string; name: string }) =>
prisma.user.create({ data }),
list: (limit = 20, cursor?: string) =>
prisma.user.findMany({
take: limit,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
}),
};
That's the whole pattern. The service never sees prisma, so swapping ORMs is a one-file change instead of a 40-file change. And when somebody adds a horrifying raw query for a perf reason, it's contained in one place where the next reviewer can find it.
Async Error Handling, The 5-Line Version
In Express 4, the safest thing you can do is wrap async handlers:
export const asyncHandler =
(fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
Then router.post('/', asyncHandler(async (req, res) => { ... })). In Express 5, you can drop the wrapper entirely — async handlers throwing rejected promises will hit the error middleware on their own.
Either way, the goal is one error path, not seventeen. Throw a custom error in the service, let it bubble through the handler, and let the error middleware translate it to a status code. The day you write if (!user) return res.status(404).json(...) in five different places is the day you start dropping changes that should have been one-liners.
Folder Layout That Survives Three Engineers
Two layouts work. Both are fine. Pick one and stop arguing.
By feature (recommended for most apps):
src/
users/
users.controller.ts
users.service.ts
users.repo.ts
users.schema.ts
users.test.ts
orders/
orders.controller.ts
orders.service.ts
...
middleware/
errors/
app.ts
server.ts
By layer (works for very small services):
src/
controllers/
services/
repositories/
middleware/
app.ts
server.ts
Feature layout scales because everything you need to change for one domain is in one folder. Layer layout looks tidier on day one and turns into a git blame nightmare on day 90.
What Express Does Not Give You
It's worth being explicit about what isn't included, so you don't pretend you have something you don't:
- Schema-driven validation — bring
zod,joi, orvalibot. - OpenAPI / Swagger — generate from your zod schemas with
zod-to-openapior write the spec by hand. - Dependency injection — most teams do fine with module exports; if you want a container,
tsyringeorawilixare reasonable. - A logger — pin
pinoandpino-httpin your first commit. - Performance — Express is fast enough for most APIs, but if your hot path measures in microseconds, evaluate Fastify.
Knowing the gaps is half the battle. The other half is filling them with the same tools every time, in every service, so engineers can move between codebases without learning a new pattern each time.
A One-Sentence Mental Model
Express is a chassis, not a car — it gives you wheels, a router, and a middleware chain, and the architecture you bolt on top is what determines whether the thing still drives in two years.






