Project Structure & Best Practices
Express.js Fundamentals
Chapter 8 of 8 · Project Structure & Best Practices
Project Structure & Best Practices
A single app.js with 50 routes works — until it doesn't. Structure is the difference between a codebase where you can find and change anything in seconds, and one where every edit requires searching three files to understand what anything does. This chapter covers the conventions the Node/Express community has settled on: how to split files, what belongs where, and the patterns that make a project testable and maintainable as it grows.
The Core Problem with One Big File
When routes, database logic, validation, and configuration all live in app.js, every concern is entangled with every other. Changing how a product is saved requires reading the route handler to understand the validation, the query to understand the schema, and the error handling to understand what breaks. Separate them, and each piece can be read, changed, and tested independently.
MVC for REST APIs
MVC (Model–View–Controller) was designed for full-stack web apps with HTML views. For a JSON API the View disappears, but the split between the other two layers — plus a Service layer between them — is exactly what you need.
Router
Maps METHOD /path to a controller function. No logic — just wiring.
File: src/routes/v1/products.js
Controller
Handles HTTP: reads req, calls a service, sends res. Validates input. Knows about HTTP status codes.
File: src/controllers/productsController.js
Service
Business logic. No req or res. Could be called from a CLI, a cron job, or a test — not just HTTP.
File: src/services/productsService.js
Model / Data Layer
Database queries or ORM models. Returns plain data. The only layer that knows SQL or the DB schema.
File: src/models/product.js
Recommended Folder Structure
Splitting app.js from server.js
This is the single most impactful structural decision. When listen() lives in app.js, importing the file starts the server — which means supertest can't import it without binding a port. Move listen() to server.js and tests can import app.js directly.
Centralised Config
Scattering process.env.X calls throughout the codebase makes it impossible to know what the app requires to run. A single config/index.js reads all env vars, applies defaults, and fails fast with a clear message if anything required is missing.
Controller Pattern
Controllers are deliberately thin. They parse the request, call a service, and send the response. No business logic, no SQL. The rule of thumb: if you could describe the controller function in one sentence that includes "asks the service to", it's right-sized.
Service Layer
The service is the heart of the application. It contains the business logic — validation rules, calculations, the decision about what counts as a "conflict" — and it's completely ignorant of HTTP. You can import and call a service function directly from a test, a CLI script, or another service.
Router — Pure Wiring
Best Practices Summary
| Practice | Why it matters |
|---|---|
| app.js exports, server.js listens | Lets supertest import the app without binding a port — the foundation of testable Express. |
| Centralise env vars in config/ | One place to see what the app needs; fail-fast catches missing vars at startup, not mid-request. |
| Controllers are thin (no business logic) | Business logic in a controller is HTTP-coupled and untestable without an HTTP round-trip. |
| Services have no req/res | Services stay reusable — callable from tests, CLIs, queues, or other services. |
| Routers are pure wiring | Routes that contain logic become impossible to test without an HTTP stack. |
| Error handler is last in app.js | Express's error-handler detection is positional — anything before the last 4-param function is regular middleware. |
| One router file per resource | Keeps files focused; limits the diff size when one resource changes. |
| require('express-async-errors') first | Must patch before any route is defined or the patch doesn't apply. |
⚠ Don't Over-Engineer Small Projects
This structure earns its cost at medium-to-large scale. For a 3-route prototype, a single app.js is fine — add layers only when a file becomes hard to read or when you need to test a piece in isolation. The key rule: always separate app.js from server.js, because you pay that cost once and it never costs you anything, but lacking it makes testing painful immediately.
Coding Challenges
Challenge 1 — Split a Monolith
You're given a single bloated app.js that has routes, database logic, and validation all in one file (write it yourself — 3 routes: GET /api/v1/notes, POST /api/v1/notes, DELETE /api/v1/notes/:id, using an in-memory array). Refactor it into the correct structure: app.js (no listen), server.js, src/config/index.js, src/routes/v1/notes.js, src/controllers/notesController.js, src/services/notesService.js, src/middleware/errorHandler.js. Validation (title required, max 200 chars) lives in the service. The controller only reads req and sends res. Write supertest tests importing app.js directly (confirm no port is bound): list notes, create valid note, 422 for missing title, 422 for title over 200 chars, delete by id, 404 for missing id.
Challenge 2 — Config, Middleware Index & Route Index
Build the infrastructure layer of a project from scratch. Write src/config/index.js that reads PORT, APP_SECRET (required — throw if missing), and NODE_ENV from process.env. Write src/middleware/index.js that exports three named functions: requestId (UUID on req.id), notFound (404 catch-all that calls next(err)), and errorHandler (4-param, sanitises 5xx). Write src/routes/v1/index.js that mounts at least two sub-routers (/health returning { status: 'ok', uptime: process.uptime() }, and /echo returning the request body). In app.js, wire everything together using the middleware index and routes index — no route logic in app.js. Write supertest tests: /api/v1/health returns 200 with uptime; /api/v1/echo echoes the POST body; every request has an x-request-id header; an unknown route returns 404; missing APP_SECRET causes the config import to throw (test this by temporarily unsetting the env var in a separate test file or by mocking the env).
Challenge 3 — Full MVC: Books API
Build a complete layered Books API following the structure from this chapter. Files required: src/models/book.js (in-memory store, findAll, findById, insert, update, remove — each returns a Promise for realistic async feel); src/services/booksService.js (business logic: validate title + author on create, validate isbn format /^\d{13}$/ if provided, enforce no duplicate isbn via model lookup, throw 409 on conflict); src/controllers/booksController.js (thin: list, show, create, update, remove — 201 + Location on create, 204 on delete); src/routes/v1/books.js (pure wiring); app.js (no listen); server.js. Write supertest tests covering the full surface: listing, creating valid and invalid books, duplicate isbn 409, update merges fields, delete returns 204, 404 for unknown id. The service must be importable and callable directly without HTTP — write at least two direct service unit tests (no supertest) to demonstrate this.
🎉 Express.js Fundamentals — Complete
All 8 chapters finished. You've covered the full Express surface:
Getting Started · Routing · Middleware · Request & Response · Static Files & Templating · Error Handling · REST API Design · Project Structure & Best Practices
A combined PDF of the course is available in the pdfs/ folder.