Project Structure & Best Practices

Express.js Fundamentals — 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
Controller
Service
Model / DB

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

src/ ├── config/ │ └── index.js ← centralised env vars, fail-fast validation ├── controllers/ │ ├── productsController.js ← HTTP layer: req → service → res │ └── usersController.js ├── middleware/ │ ├── asyncHandler.js ← async wrapper (or use express-async-errors) │ ├── errorHandler.js ← 4-param error handler │ └── requestId.js ← cross-cutting concerns ├── models/ │ └── product.js ← DB queries or ORM model ├── routes/ │ └── v1/ │ ├── index.js ← mounts all v1 routers │ ├── products.js ← router for /products │ └── users.js ← router for /users ├── services/ │ └── productsService.js ← business logic, no HTTP awareness └── utils/ └── createError.js ← shared helpers app.js ← creates + configures app, exports it, no listen() server.js ← imports app, calls app.listen()

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.

// app.js — creates and configures the app, then exports it require('express-async-errors'); const express = require('express'); const helmet = require('helmet'); const v1Routes = require('./src/routes/v1'); const { errorHandler, notFound } = require('./src/middleware'); const app = express(); app.use(helmet()); app.use(express.json()); app.use('/api/v1', v1Routes); app.use(notFound); app.use(errorHandler); module.exports = app; // ← exported, not started // server.js — the only file that calls listen() const app = require('./app'); const config = require('./src/config'); const server = app.listen(config.PORT, () => { console.log(`Listening on port ${config.PORT}`); }); // Graceful shutdown process.on('SIGTERM', () => server.close(() => process.exit(0)));

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.

// src/config/index.js const required = ['DB_URL', 'JWT_SECRET']; const missing = required.filter((k) => !process.env[k]); if (missing.length > 0) { throw new Error(`Missing required env vars: ${missing.join(', ')}`); } module.exports = { PORT: process.env.PORT || 3000, DB_URL: process.env.DB_URL, JWT_SECRET: process.env.JWT_SECRET, NODE_ENV: process.env.NODE_ENV || 'development', isDev: process.env.NODE_ENV !== 'production', };

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.

// src/controllers/productsController.js const productsService = require('../services/productsService'); const createError = require('../utils/createError'); async function list(req, res) { const products = await productsService.getAll(); res.json({ data: products, meta: { total: products.length } }); } async function show(req, res) { const product = await productsService.findById(req.params.id); if (!product) throw createError(404, `Product ${req.params.id} not found`); res.json({ data: product }); } async function create(req, res) { const product = await productsService.create(req.body); res.status(201) .set('Location', `/api/v1/products/${product.id}`) .json({ data: product }); } async function update(req, res) { const product = await productsService.update(req.params.id, req.body); if (!product) throw createError(404, `Product ${req.params.id} not found`); res.json({ data: product }); } async function remove(req, res) { const ok = await productsService.remove(req.params.id); if (!ok) throw createError(404, `Product ${req.params.id} not found`); res.status(204).end(); } module.exports = { list, show, create, update, remove };

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.

// src/services/productsService.js const Product = require('../models/product'); const createError = require('../utils/createError'); async function create(data) { // Validation lives here, not in the controller const errors = []; if (!data?.name?.trim()) errors.push({ field: 'name', message: 'required' }); if (data?.price == null || isNaN(+data.price) || +data.price <= 0) errors.push({ field: 'price', message: 'must be a positive number' }); if (errors.length) { const err = createError(422, 'Validation failed'); err.details = errors; throw err; } const existing = await Product.findByName(data.name.trim()); if (existing) throw createError(409, 'A product with that name already exists'); return Product.insert({ name: data.name.trim(), price: +data.price }); } async function getAll() { return Product.findAll(); } async function findById(id) { return Product.findById(id); } async function update(id, data) { return Product.update(id, data); } async function remove(id) { return Product.remove(id); } module.exports = { create, getAll, findById, update, remove };

Router — Pure Wiring

// src/routes/v1/products.js const router = require('express').Router(); const controller = require('../../controllers/productsController'); router.route('/') .get(controller.list) .post(controller.create); router.route('/:id') .get(controller.show) .patch(controller.update) .delete(controller.remove); module.exports = router; // src/routes/v1/index.js — mounts all v1 routers const router = require('express').Router(); router.use('/products', require('./products')); router.use('/users', require('./users')); module.exports = router;

Best Practices Summary

PracticeWhy it matters
app.js exports, server.js listensLets 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/resServices stay reusable — callable from tests, CLIs, queues, or other services.
Routers are pure wiringRoutes that contain logic become impossible to test without an HTTP stack.
Error handler is last in app.jsExpress's error-handler detection is positional — anything before the last 4-param function is regular middleware.
One router file per resourceKeeps files focused; limits the diff size when one resource changes.
require('express-async-errors') firstMust 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.

View sample solution ↗

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).

View sample solution ↗

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.

View sample solution ↗

🎉 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.