Skip to content
Express.js best practices 4 min read

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:

LayerResponsibilityKnows about
RouteMaps an HTTP method + path to a handlerURLs, middleware
ControllerParses the request, calls a service, shapes the responsereq/res, HTTP status
ServiceBusiness rules and orchestrationDomain logic, other services
RepositoryReads and writes persistent dataThe 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 from server.js (which calls app.listen). Exporting the configured app lets 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/res confined 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 config module that crashes loudly on missing values.
  • Separate app.js (configuration) from server.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.
Last updated June 14, 2026
Was this helpful?