Project Structure Best Practices
Express is deliberately unopinionated: it ships a router and middleware and leaves the rest to you. That freedom is a gift in a prototype and a liability in a growing codebase, where everything tends to pile into one app.js until nobody can find anything. A clear, layered structure pays for itself the moment a second developer joins or the app outlives its first feature. This page lays out a structure that scales — separating HTTP concerns from business logic from data access — and the conventions that keep it healthy.
Separate concerns into layers
The single most valuable rule is to split your code by responsibility, not by framework feature. A request flows through distinct layers, each with one job:
| Layer | Responsibility | Knows about |
|---|---|---|
| Route | Maps an HTTP method + path to a handler | URLs, middleware |
| Controller | Parses the request, calls a service, shapes the response | req/res, HTTP status |
| Service | Business rules and orchestration | Domain logic, other services |
| Repository | Reads and writes persistent data | The database/ORM |
The key invariant: only controllers touch req and res, and only repositories touch the database. Services contain pure business logic and stay framework-agnostic, which makes them trivial to unit test without spinning up a server.
A typical tree for a feature-organized app:
src/
config/
index.js # centralized config, reads process.env once
routes/
index.js # mounts all feature routers
users.routes.js
controllers/
users.controller.js
services/
users.service.js
repositories/
users.repository.js
middleware/
error-handler.js
authenticate.js
app.js # builds the Express app
server.js # starts the HTTP listener
Tip: Keep
app.js(which configures middleware and routes) separate fromserver.js(which callsapp.listen). Exporting the configuredapplets your tests drive it with Supertest without ever binding a port.
A layer in practice
Here is one feature wired end to end. Notice how thin the controller stays — it has no business logic, only translation between HTTP and the service.
// repositories/users.repository.js
import { db } from '../config/db.js';
export async function findById(id) {
return db('users').where({ id }).first();
}
export async function insert(user) {
const [created] = await db('users').insert(user).returning('*');
return created;
}
// services/users.service.js
import * as repo from '../repositories/users.repository.js';
export async function getUser(id) {
const user = await repo.findById(id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
return user;
}
export async function createUser(data) {
// business rule: emails are stored lowercased
return repo.insert({ ...data, email: data.email.toLowerCase() });
}
// controllers/users.controller.js
import * as service from '../services/users.service.js';
export async function show(req, res) {
const user = await service.getUser(req.params.id);
res.json(user);
}
export async function create(req, res) {
const user = await service.createUser(req.body);
res.status(201).json(user);
}
// routes/users.routes.js
import { Router } from 'express';
import * as controller from '../controllers/users.controller.js';
const router = Router();
router.get('/:id', controller.show);
router.post('/', controller.create);
export default router;
A GET /users/42 for a missing record now produces a clean response because the error propagates from the service to a central handler:
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "error": "User not found" }
Centralize configuration
Scatter process.env.X across the codebase and you will eventually deploy with a typo’d variable that fails silently. Read environment variables in exactly one module, validate them at boot, and import a typed config object everywhere else.
// config/index.js
const required = ['DATABASE_URL', 'JWT_SECRET'];
for (const key of required) {
if (!process.env[key]) throw new Error(`Missing env var: ${key}`);
}
export const config = {
port: Number(process.env.PORT) || 3000,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
isProd: process.env.NODE_ENV === 'production',
};
This way a misconfigured deploy crashes immediately with a precise message, rather than throwing a confusing error deep inside a request hours later.
Mount routers, don’t list routes
As the app grows, register each feature’s router through a single aggregator and mount it under a versioned prefix. This keeps app.js short and makes the URL surface obvious at a glance.
// routes/index.js
import { Router } from 'express';
import users from './users.routes.js';
const api = Router();
api.use('/users', users);
export default api;
// app.js
import express from 'express';
import api from './routes/index.js';
import { errorHandler } from './middleware/error-handler.js';
export const app = express();
app.use(express.json());
app.use('/api/v1', api);
app.use(errorHandler); // must be last, after all routes
Avoid fat controllers
A “fat controller” mixes validation, business rules, database calls, and response formatting in one function. It cannot be reused, is painful to test, and grows unboundedly. When a controller exceeds a handful of lines or reaches for the database directly, push that logic down into a service or repository. In Express 5, async errors thrown from handlers are forwarded to your error middleware automatically; in Express 4 you must catch and call next(err) (or use a wrapper), so keep that handling in one place rather than repeating try/catch in every controller.
Best Practices
- Keep
req/resconfined to controllers and the database confined to repositories — services stay pure and unit-testable. - Organize by feature once you have more than a few entities, so related route, controller, and service files live together.
- Read and validate environment variables in a single
configmodule that crashes loudly on missing values. - Separate
app.js(configuration) fromserver.js(listening) to make end-to-end testing painless. - Register one centralized error handler as the last middleware, and let async errors flow to it instead of duplicating
try/catch. - Mount routers under a versioned prefix (
/api/v1) so breaking changes get a clean migration path. - Resist putting logic in controllers — if it isn’t HTTP translation, it belongs in a service.