Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Express.js Beyond Basics: Application Structure for Maintainability

Capítulo 1

Estimated reading time: 9 minutes

+ Exercise

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

Responsibilities and boundaries

FolderResponsibilityMust not do
app/bootstrapWire the app: create Express instance, register global middleware, mount routes, register error handlersContain business rules or data access
routesDefine URL structure and attach middleware + controller handlersImplement business logic
controllersTranslate HTTP to application calls: read params/body, call service, map result to responseKnow database details; embed complex rules
servicesBusiness logic and orchestration: validation beyond shape, invariants, workflows, transactionsDepend on Express (req/res)
repositoriesData access: query DB, call external persistence APIs, map rows/documents to domain objectsContain HTTP concerns
middlewaresCross-cutting HTTP concerns: auth, request IDs, rate limiting, parsing, etc.Be used as a dumping ground for business logic
validatorsRequest shape validation (schema), reusable per routeTalk to DB (keep it fast and pure)
configCentralized configuration and environment parsingImport app modules (avoid cycles)
utilsSmall 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 App

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.js exporting handler functions.
  • Services: users.service.js exporting verbs like registerUser, getUserById.
  • Repositories: users.repository.js exporting data operations like findById, create.
  • Middlewares: *.middleware.js exporting a function returning (req,res,next).
  • Validators: *.validators.js exporting pure validation functions.

Module exports

  • Export a small surface area: only what other layers need.
  • Prefer exporting plain functions/objects (CommonJS module.exports shown 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.js later) 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.js with 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: validateauth (if needed) → controller.
// Typical route pipeline (conceptual) app.use(globalMiddlewares...) router.METHOD('/path',   routeMiddlewares...,   controllerHandler // calls service // service calls repository )

Now answer the exercise about the content:

Which layering choice best supports maintainability by keeping stable seams between HTTP concerns, business logic, and data access?

You are right! Congratulations, now go to the next page

You missed! Try again.

Maintainable structure relies on separation of concerns: routes wire middleware + controllers, controllers translate HTTP to service calls, services contain business decisions without Express, and repositories handle data access.

Next chapter

Middleware Design in Express.js: Composition, Order, and Reuse

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.