App Structure

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.

4 min read Level 1/5 #express#structure#project-layout
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 →