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.

TypeScript src/users/users.controller.ts
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 };
TypeScript src/users/users.service.ts
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:

TypeScript src/app.ts
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.

Layered diagram of an Express application showing the middleware chain on top, a routes layer, a thin controller, a service with business rules, and a repository wrapping the database, with cross-cutting concerns like logging and request ID flowing through all layers
Where each piece of an Express app belongs

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.

TypeScript src/users/users.repo.ts
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:

TypeScript src/utils/asyncHandler.ts
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):

Text
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):

Text
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, or valibot.
  • OpenAPI / Swagger — generate from your zod schemas with zod-to-openapi or write the spec by hand.
  • Dependency injection — most teams do fine with module exports; if you want a container, tsyringe or awilix are reasonable.
  • A logger — pin pino and pino-http in 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.