A Project Layout That Doesn't Hurt at Scale
App Structure
A small Express app can fit in one file. A real one shouldn't. Here's a layout that scales.
What you'll learn
- Split app setup from server start
- Organize routes, controllers, services
- Use environment-based config
A 50-line Express app can live in one file. A 5000-line one shouldn’t. Here’s a layout that grows without pain.
The Standard Tree
my-api/
├── src/
│ ├── index.js ← starts the server (only)
│ ├── app.js ← builds the app (no listen)
│ ├── config/
│ │ └── env.js ← parsed/validated env
│ ├── routes/
│ │ ├── users.js
│ │ └── posts.js
│ ├── controllers/
│ │ ├── users.js
│ │ └── posts.js
│ ├── services/
│ │ ├── users.js
│ │ └── posts.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── error.js
│ ├── db/
│ │ └── client.js
│ └── lib/ ← small utilities
├── tests/
├── .env.example
├── package.json
└── tsconfig.json (if TS) Separate App From Server
// src/app.js — builds the app, doesn't listen
import express from "express";
import users from "./routes/users.js";
import posts from "./routes/posts.js";
import errorHandler from "./middleware/error.js";
export function buildApp() {
const app = express();
app.use(express.json());
app.use("/api/users", users);
app.use("/api/posts", posts);
app.use(errorHandler);
return app;
} // src/index.js — only starts the server
import { buildApp } from "./app.js";
import { env } from "./config/env.js";
const app = buildApp();
app.listen(env.PORT, () => {
console.log(`up on :${env.PORT}`);
}); This separation makes the app testable — your tests import
buildApp() and exercise routes without ever binding a port.
Routes Are Thin
// src/routes/users.js
import { Router } from "express";
import * as users from "../controllers/users.js";
const r = Router();
r.get("/", users.list);
r.post("/", users.create);
r.get("/:id", users.get);
r.patch("/:id", users.update);
r.delete("/:id", users.remove);
export default r; A route file is a map: path → controller. No business logic.
Controllers Are Glue
// src/controllers/users.js
import * as userService from "../services/users.js";
export async function list(req, res) {
const users = await userService.list();
res.json(users);
}
export async function create(req, res) {
const user = await userService.create(req.body);
res.status(201).json(user);
} Read input, call a service, send a response. Zero domain logic.
Services Hold the Domain
// src/services/users.js
import { db } from "../db/client.js";
export async function list() {
return db.users.findMany();
}
export async function create(data) {
validate(data);
return db.users.create(data);
} The actual business logic lives here. Testable in isolation.
Why This Layered Structure
- Each layer has one job — easy to scan, easy to test
- Services are decoupled from Express — reuse them in CLIs, queues, GraphQL
- Adding a new endpoint touches at most 3 files
Don’t over-engineer day one. Start with app.js + one route file.
Split out controllers when handlers get long. Split services when
business logic appears in multiple controllers.
End of Chapter
That wraps the foundations. Next chapter: routing deep dive — sub- routers, async handlers, regex, 404s, app.param.
The Router Class →