Why structure matters (and what “maintainable” means in Express)
A maintainable Express codebase makes change predictable. You should be able to add a new endpoint, modify business rules, or swap a database without touching unrelated files. The main technique is separation of concerns: each layer has a single responsibility, clear inputs/outputs, and minimal knowledge of other layers.
This chapter defines a reference layout and the boundaries between modules. The goal is not “more folders”, but stable seams: routing does not contain business logic, controllers do not contain SQL, and services do not depend on Express.
Reference folder structure
src/ app/ bootstrap/ createApp.js http.js errorHandling.js server.js routes/ index.js users.routes.js controllers/ users.controller.js services/ users.service.js repositories/ users.repository.js middlewares/ auth.middleware.js validate.middleware.js requestId.middleware.js validators/ users.validators.js config/ index.js env.js logger.js utils/ asyncHandler.js errors.js pick.jsResponsibilities and boundaries
| Folder | Responsibility | Must not do |
|---|---|---|
app/bootstrap | Wire the app: create Express instance, register global middleware, mount routes, register error handlers | Contain business rules or data access |
routes | Define URL structure and attach middleware + controller handlers | Implement business logic |
controllers | Translate HTTP to application calls: read params/body, call service, map result to response | Know database details; embed complex rules |
services | Business logic and orchestration: validation beyond shape, invariants, workflows, transactions | Depend on Express (req/res) |
repositories | Data access: query DB, call external persistence APIs, map rows/documents to domain objects | Contain HTTP concerns |
middlewares | Cross-cutting HTTP concerns: auth, request IDs, rate limiting, parsing, etc. | Be used as a dumping ground for business logic |
validators | Request shape validation (schema), reusable per route | Talk to DB (keep it fast and pure) |
config | Centralized configuration and environment parsing | Import app modules (avoid cycles) |
utils | Small generic helpers (no app knowledge) | Become a “misc” bucket for app logic |
Dependency direction (to reduce coupling)
A simple rule: dependencies should point “inward” toward business logic, not outward toward frameworks.
- Controllers may depend on services.
- Services may depend on repositories (through interfaces/constructor injection).
- Repositories depend on infrastructure (DB client), but should not depend on Express.
- Services should be testable without Express by passing plain objects.
In practice, this means: do not pass req into services; extract what you need in the controller and pass primitives/DTOs.
Request flow through the layers
Use this mental model for every request:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
- Global middleware runs first (e.g., request ID, JSON parsing, auth initialization).
- Router matches method + path and runs route-specific middleware (e.g., schema validation).
- Controller extracts inputs and calls a service.
- Service applies business rules and calls repositories.
- Repository performs data access and returns data to the service.
- Controller maps the result to an HTTP response.
- Error middleware handles thrown errors and formats responses consistently.
Keep each step small and predictable: middleware should enrich the request or short-circuit; controllers should be thin; services should be where “decisions” live.
Step-by-step: bootstrap an app with this structure
1) Configuration
Centralize environment parsing so other modules don’t read process.env directly.
// src/config/env.js const required = (name) => { const v = process.env[name]; if (!v) throw new Error(`Missing env var: ${name}`); return v; }; module.exports = { NODE_ENV: process.env.NODE_ENV || 'development', PORT: Number(process.env.PORT || 3000), DATABASE_URL: process.env.DATABASE_URL || null, // Example of required: // JWT_SECRET: required('JWT_SECRET'), };// src/config/index.js const env = require('./env'); module.exports = { env };2) Create the Express app (bootstrap)
createApp should only assemble the HTTP pipeline.
// src/app/bootstrap/createApp.js const express = require('express'); const routes = require('../../routes'); const { notFoundHandler, errorHandler } = require('./errorHandling'); const requestId = require('../../middlewares/requestId.middleware'); module.exports = function createApp() { const app = express(); app.use(express.json()); app.use(requestId()); app.use('/api', routes); app.use(notFoundHandler); app.use(errorHandler); return app; };// src/app/bootstrap/errorHandling.js const { AppError } = require('../../utils/errors'); function notFoundHandler(req, res) { res.status(404).json({ error: 'Not Found' }); } function errorHandler(err, req, res, next) { const status = err instanceof AppError ? err.status : 500; const payload = { error: err.message || 'Internal Server Error' }; if (err.code) payload.code = err.code; res.status(status).json(payload); } module.exports = { notFoundHandler, errorHandler };// src/app/server.js const createApp = require('./bootstrap/createApp'); const { env } = require('../config'); const app = createApp(); app.listen(env.PORT, () => { // Intentionally minimal; use a logger module in real apps. console.log(`Listening on :${env.PORT}`); });3) Route composition
Use a single entry point that mounts feature routers. This keeps the URL map discoverable.
// src/routes/index.js const express = require('express'); const usersRoutes = require('./users.routes'); const router = express.Router(); router.use('/users', usersRoutes); module.exports = router;Evolving example: “Users” feature (thin controller, service, repository)
This example starts simple (in-memory repository) and is designed to evolve later (database, auth, pagination, etc.) without changing the overall shape.
1) Define validators (request shape)
Validators should focus on request structure (types, required fields). Keep them deterministic and fast.
// src/validators/users.validators.js function validateCreateUser(body) { const errors = []; if (!body || typeof body !== 'object') errors.push('Body must be an object'); if (!body.email || typeof body.email !== 'string') errors.push('email is required'); if (!body.name || typeof body.name !== 'string') errors.push('name is required'); return { ok: errors.length === 0, errors }; } module.exports = { validateCreateUser };// src/middlewares/validate.middleware.js const { AppError } = require('../utils/errors'); module.exports = function validate(validatorFn) { return (req, res, next) => { const result = validatorFn(req.body); if (!result.ok) return next(new AppError(400, result.errors.join(', '), 'VALIDATION_ERROR')); next(); }; };2) Implement the repository (data access boundary)
Start with an in-memory store. Later, you can replace this module with a DB-backed implementation without changing controllers.
// src/repositories/users.repository.js const users = new Map(); let seq = 1; async function create({ email, name }) { const id = String(seq++); const user = { id, email, name, createdAt: new Date().toISOString() }; users.set(id, user); return user; } async function findByEmail(email) { for (const u of users.values()) if (u.email === email) return u; return null; } async function findById(id) { return users.get(String(id)) || null; } module.exports = { create, findByEmail, findById };3) Implement the service (business logic)
Services enforce rules and orchestrate repositories. They should not know about HTTP status codes directly; throw typed errors instead.
// src/services/users.service.js const usersRepo = require('../repositories/users.repository'); const { AppError } = require('../utils/errors'); async function registerUser({ email, name }) { const existing = await usersRepo.findByEmail(email); if (existing) throw new AppError(409, 'Email already in use', 'EMAIL_TAKEN'); const user = await usersRepo.create({ email, name }); return user; } async function getUserById(id) { const user = await usersRepo.findById(id); if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); return user; } module.exports = { registerUser, getUserById };4) Implement the controller (HTTP mapping)
Controllers extract inputs from the request and map service results to responses. Keep them small.
// src/controllers/users.controller.js const usersService = require('../services/users.service'); const asyncHandler = require('../utils/asyncHandler'); const createUser = asyncHandler(async (req, res) => { const { email, name } = req.body; const user = await usersService.registerUser({ email, name }); res.status(201).json({ data: user }); }); const getUser = asyncHandler(async (req, res) => { const user = await usersService.getUserById(req.params.id); res.status(200).json({ data: user }); }); module.exports = { createUser, getUser };// src/utils/asyncHandler.js module.exports = function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); };5) Wire routes with middleware + controller
Routes define the public API: URL, method, and which middleware applies.
// src/routes/users.routes.js const express = require('express'); const usersController = require('../controllers/users.controller'); const validate = require('../middlewares/validate.middleware'); const { validateCreateUser } = require('../validators/users.validators'); const router = express.Router(); router.post('/', validate(validateCreateUser), usersController.createUser); router.get('/:id', usersController.getUser); module.exports = router;Cross-cutting middleware example: request IDs
Cross-cutting concerns belong in middleware. A request ID helps correlate logs and errors without polluting controllers/services.
// src/middlewares/requestId.middleware.js const crypto = require('crypto'); module.exports = function requestId() { return (req, res, next) => { const id = req.headers['x-request-id'] || crypto.randomUUID(); req.requestId = id; res.setHeader('x-request-id', id); next(); }; };Conventions that keep the structure stable
Naming
- Routes:
users.routes.js(plural, feature-based). - Controllers:
users.controller.jsexporting handler functions. - Services:
users.service.jsexporting verbs likeregisterUser,getUserById. - Repositories:
users.repository.jsexporting data operations likefindById,create. - Middlewares:
*.middleware.jsexporting a function returning(req,res,next). - Validators:
*.validators.jsexporting pure validation functions.
Module exports
- Export a small surface area: only what other layers need.
- Prefer exporting plain functions/objects (CommonJS
module.exportsshown here). If you use ESM, keep the same boundaries. - Keep files focused: if a module grows too large, split by sub-feature (e.g.,
services/users/registration.service.jslater) without changing the dependency direction.
Dependency direction rules (practical)
- Controllers may import services; services must not import controllers.
- Services may import repositories; repositories must not import services.
- Validators must not import repositories (avoid I/O in validation middleware).
- Config must not import app code (prevents circular dependencies and side effects).
Where to put “shared” code
Before adding to utils, ask: is it truly generic? If it knows about “users”, “orders”, or your error codes, it belongs in a feature module (service/controller) instead.
How this example will evolve later (without changing the shape)
- Replace
users.repository.jswith a DB-backed implementation; keep the same exported functions. - Add authentication middleware to routes (e.g.,
router.get('/:id', auth(), ...)) without touching services. - Introduce DTO mapping in controllers (e.g., hide internal fields) without changing repository/service contracts.
- Add more validators per route (update, list with pagination) without moving logic into controllers.
Minimal checklist when adding a new endpoint
- Add/extend a validator in
validators/(shape only). - Add a controller handler in
controllers/(extract inputs, call service, respond). - Add/extend a service function in
services/(rules, orchestration). - Add/extend repository functions in
repositories/(data access). - Wire it in
routes/with middleware order:validate→auth(if needed) → controller.
// Typical route pipeline (conceptual) app.use(globalMiddlewares...) router.METHOD('/path', routeMiddlewares..., controllerHandler // calls service // service calls repository )