Skip to content
Express.js ex getting-started 5 min read

Structuring an Express Project

A Hello World app fits in one file, but a real application does not. As soon as you have multiple routes, a database, authentication, and business logic, cramming everything into app.js becomes a tangle that is hard to test and harder to change. Express is deliberately unopinionated about folder layout, which means the structure is your responsibility. This page lays out a proven, scalable layout — routes, controllers, services, models, middleware, and config — and explains the separation of concerns that keeps each layer focused and replaceable.

Why structure matters

A flat single-file app couples three very different jobs together: deciding which code runs for a URL (routing), shaping the HTTP request and response (the controller), and the actual what-it-does work (business logic and data access). When those concerns live in one place, you cannot test the logic without spinning up a server, you cannot reuse the logic from a background job, and every new feature makes the file longer and riskier to touch.

Separating concerns into layers gives each piece one reason to change. Routers map URLs to handlers. Controllers translate between HTTP and your domain. Services hold the business rules. Models own data access. The payoff is real: independent unit tests, reusable logic, and a codebase a new teammate can navigate.

The layout below scales cleanly from a small API to a large team project. The entry point stays thin and the src/ tree holds everything else.

my-app/
├── src/
│   ├── app.js               # builds the Express app (middleware, routes)
│   ├── server.js            # imports app, reads PORT, calls listen()
│   ├── config/
│   │   └── index.js         # env-driven config (port, db url, secrets)
│   ├── routes/
│   │   ├── index.js         # mounts all feature routers
│   │   └── users.routes.js  # URL → controller mapping for users
│   ├── controllers/
│   │   └── users.controller.js
│   ├── services/
│   │   └── users.service.js # business logic, no req/res here
│   ├── models/
│   │   └── user.model.js     # data access / ORM schema
│   ├── middleware/
│   │   ├── error.middleware.js
│   │   └── auth.middleware.js
│   └── utils/
│       └── logger.js
├── tests/
├── .env
└── package.json

Tip: Split app.js (which only builds the app) from server.js (which starts it). Importing the app without binding a port lets your tests use Supertest against the app object directly, with no live socket.

The layers in practice

Config

Centralize every environment-dependent value so the rest of the code never reads process.env directly. This makes defaults explicit and config easy to mock.

// src/config/index.js
require("dotenv").config();

module.exports = {
  port: process.env.PORT || 3000,
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
};

Routes

A router is a mini-application: it owns a set of paths and delegates each one to a controller method. It contains no logic — just the URL-to-handler wiring.

// src/routes/users.routes.js
const { Router } = require("express");
const controller = require("../controllers/users.controller");

const router = Router();

router.get("/", controller.list);
router.get("/:id", controller.getById);
router.post("/", controller.create);

module.exports = router;

A single index file mounts each feature router under its base path, so app.js only needs to wire up one thing.

// src/routes/index.js
const { Router } = require("express");
const usersRouter = require("./users.routes");

const router = Router();
router.use("/users", usersRouter);

module.exports = router;

Controllers

Controllers are the bridge between HTTP and your domain. They read req, call a service, and shape res. They should contain no business rules — that keeps them thin and easy to read.

// src/controllers/users.controller.js
const usersService = require("../services/users.service");

exports.list = async (req, res, next) => {
  try {
    const users = await usersService.findAll();
    res.json(users);
  } catch (err) {
    next(err); // hand off to error middleware
  }
};

exports.getById = async (req, res, next) => {
  try {
    const user = await usersService.findById(req.params.id);
    if (!user) return res.status(404).json({ error: "Not found" });
    res.json(user);
  } catch (err) {
    next(err);
  }
};

Services and models

Services hold the business logic and know nothing about req or res — which means you can call them from a route, a CLI script, or a queue worker. Models encapsulate data access.

// src/services/users.service.js
const User = require("../models/user.model");

exports.findAll = () => User.find();
exports.findById = (id) => User.findById(id);
exports.create = (data) => User.create(data);

Wiring it together

app.js assembles the application: global middleware first, then the mounted routes, then the error handler last so it can catch anything thrown upstream.

// src/app.js
const express = require("express");
const routes = require("./routes");
const errorHandler = require("./middleware/error.middleware");

const app = express();

app.use(express.json());
app.use("/api", routes); // → GET /api/users, etc.
app.use(errorHandler);   // must be registered last

module.exports = app;
// src/server.js
const app = require("./app");
const { port } = require("./config");

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

A GET /api/users request now flows cleanly through each layer:

Output:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"id":"1","name":"Ada Lovelace"},{"id":"2","name":"Alan Turing"}]

Note: In Express 5 a rejected async controller is forwarded to your error middleware automatically, so the try/catch + next(err) shown above becomes optional. In Express 4 it is required. See Express 4 vs 5.

Layer responsibilities at a glance

LayerResponsibilityShould NOT
RoutesMap URLs and HTTP verbs to handlersContain logic
ControllersRead req, call services, shape resHold business rules or DB queries
ServicesBusiness logic and orchestrationTouch req/res
ModelsData access and schemaKnow about HTTP
MiddlewareCross-cutting concerns (auth, errors, logging)Contain feature logic
ConfigEnvironment-driven settingsBe scattered through the code

Best Practices

  • Keep the entry file thin: app.js builds the app, server.js starts it — split them so tests can import the app without a live port.
  • Put zero business logic in routers; their only job is mapping a path to a controller method.
  • Keep controllers thin and services pure — services that never touch req/res are trivially unit-testable and reusable.
  • Register your error-handling middleware last so it catches errors forwarded by next(err) from any layer.
  • Read environment values through a single config module instead of sprinkling process.env across the codebase.
  • Group files by feature (users.routes.js, users.controller.js, users.service.js) as the app grows, so related code stays together.
  • Add a tests/ folder early and mirror your src/ structure inside it.
Last updated June 14, 2026
Was this helpful?