express.Router() splits routes into files, but a growing application also needs a rule for what goes where. The Model-View-Controller pattern, extended with a service layer, gives every file one job: routes map URLs, controllers translate HTTP into function calls, services hold business rules, and models talk to the database. After this lesson you will be able to lay out an Express project that stays readable at fifty routes and is easy to test.
| Layer | Folder | Knows about | Must not know about |
| --- | --- | --- | --- |
| Routes | src/routes | URLs, HTTP verbs, which middleware to attach | Business rules |
| Controllers | src/controllers | req, res, status codes | Database queries |
| Services | src/services | Business rules, validation of state, orchestration | req and res |
| Models | src/models | Schemas, queries, persistence | HTTP |
| Views | src/views | Templates rendered by controllers | Data access |
The dependency direction is one-way: routes call controllers, controllers call services, services call models. Nothing lower in the stack imports anything higher, so a service can be reused by a CLI script, a cron job or a test without an HTTP request.
src/
app.js # builds and exports the Express app
server.js # reads config, connects DB, calls app.listen
config/index.js
routes/
index.js # mounts every feature router
todo.routes.js
controllers/todo.controller.js
services/todo.service.js
models/todo.model.js
middleware/
error-handler.js
validate.js
utils/app-error.jsSplitting app.js from server.js is the single most useful decision in this layout. Tests import app and drive it with Supertest without opening a port, while server.js owns the side effects (database connection, listening, graceful shutdown).
The model is a plain data-access module. This example uses an in-memory array so the structure is visible; later lessons swap it for Mongoose or Prisma without touching controllers.
// src/models/todo.model.js
const todos = [];
let nextId = 1;
export const TodoModel = {
findAll: async () => todos,
findById: async (id) => todos.find((t) => t.id === id) ?? null,
insert: async (data) => {
const todo = { id: nextId++, done: false, ...data };
todos.push(todo);
return todo;
},
};The service enforces rules and throws typed errors; it never sees req or res:
// src/services/todo.service.js
import { TodoModel } from "../models/todo.model.js";
import { AppError } from "../utils/app-error.js";
export const findAll = () => TodoModel.findAll();
export async function create({ title }) {
if (!title?.trim()) throw new AppError("Title is required", 400);
return TodoModel.insert({ title: title.trim() });
}
export async function getOrFail(id) {
const todo = await TodoModel.findById(Number(id));
if (!todo) throw new AppError(`Todo ${id} not found`, 404);
return todo;
}The controller is thin: read the request, call the service, shape the response. Because Express 5 forwards rejected promises to the error middleware, no try/catch is needed.
// src/controllers/todo.controller.js
import * as todoService from "../services/todo.service.js";
export const list = async (req, res) => res.json({ data: await todoService.findAll() });
export const show = async (req, res) => res.json({ data: await todoService.getOrFail(req.params.id) });
export const create = async (req, res) =>
res.status(201).json({ data: await todoService.create(req.body) });Finally the wiring:
// src/routes/index.js
import { Router } from "express";
import todoRoutes from "./todo.routes.js";
const router = Router();
router.use("/todos", todoRoutes);
export default router;
// src/app.js
import express from "express";
import routes from "./routes/index.js";
import { errorHandler } from "./middleware/error-handler.js";
const app = express();
app.use(express.json());
app.use("/api", routes);
app.use(errorHandler);
export default app;
// src/server.js
import app from "./app.js";
app.listen(3000, () => console.log("API on http://localhost:3000/api/todos"));For server-rendered pages, a controller calls res.render("todos/index", { todos }) instead of res.json(); services and models stay identical, so one codebase can serve both HTML and JSON.
req into services "for convenience". Pass the specific values instead (req.user.id, req.body), so services stay pure functions.Which layer is the only one allowed to touch `req` and `res`?
req/res so logic is reusable and testable.app.js and keep side effects such as listen() in server.js.Next lesson: Environment Variables and Configuration — keep secrets and per-environment settings out of your code with .env files and a validated config module.