Node.jsBackendFull-Stack

Express Router: Structuring Large Applications

TT
TopicTrick Team
Express Router: Structuring Large Applications

Express Router: Structuring Large Applications

A single app.js file with every route, every middleware, and every database call is fine for a tutorial. It is a disaster for a real product. When one file reaches 300 lines, reading it feels like archaeology. When a team of three people edits it simultaneously, git conflicts become a part-time job.

Express Router exists to fix this. It lets you split your application into self-contained route modules, each responsible for one slice of your API. Combined with a deliberate folder structure, it turns a tangled monolith into something you can actually navigate.

This is Module 13 of the Node.js Full‑Stack Developer course.


The Problem with One Big File

A minimal Express app that has grown beyond its means looks like this:

js
// app.js — the warning signs
app.get('/users', ...)
app.post('/users', ...)
app.get('/users/:id', ...)
app.put('/users/:id', ...)
app.delete('/users/:id', ...)

app.get('/products', ...)
app.post('/products', ...)
// ... 200 more lines

Every concern — routing, validation, business logic, database queries — lives in the same file. There is no way to read just the user-related code, no way to test a feature in isolation, and no way to delete a feature without reading every line to find what belongs to it.


express.Router() — The Foundation

express.Router() creates a mini-application that has its own middleware stack and route handlers, but no HTTP server. You build it like a small Express app, then mount it into the real app.

js
// routes/users.js
import express from 'express';
const router = express.Router();

router.get('/', (req, res) => {
  res.json({ users: [] });
});

router.post('/', (req, res) => {
  res.status(201).json({ created: true });
});

router.get('/:id', (req, res) => {
  res.json({ id: req.params.id });
});

export default router;
js
// app.js
import express from 'express';
import usersRouter from './routes/users.js';

const app = express();
app.use(express.json());

app.use('/api/users', usersRouter);   // mount at /api/users

app.listen(3000);

Every route defined on usersRouter is now prefixed with /api/users. router.get('/') becomes GET /api/users. router.get('/:id') becomes GET /api/users/:id. The router has no idea what prefix it will be mounted on — that decision belongs to app.js.


Router Middleware

Middleware attached to a router applies only to that router's routes — not the whole application.

js
// routes/admin.js
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';

const router = express.Router();

// Every route in this router requires authentication
router.use(requireAuth);

// Only admin routes need the admin role check
router.use(requireRole('admin'));

router.get('/users', adminController.listUsers);
router.delete('/users/:id', adminController.deleteUser);

export default router;
js
// app.js — public routes come first, no auth needed
app.use('/api/auth', authRouter);
app.use('/api/products', productsRouter);

// Admin routes are protected at the router level
app.use('/api/admin', adminRouter);

This is cleaner than adding requireAuth to every individual route definition.


Project Structure: Layer-Based vs Feature-Based

Layer-Based (avoid for non-trivial apps)

text
src/
  routes/
    users.js
    products.js
    orders.js
  controllers/
    users.js
    products.js
    orders.js
  services/
    users.js
    products.js
    orders.js
  models/
    User.js
    Product.js
    Order.js

Adding the "orders" feature means touching four directories. Deleting it means hunting through four directories. Every feature change creates widespread file-system churn.

Feature-Based (recommended)

text
src/
  features/
    users/
      users.router.js
      users.controller.js
      users.service.js
      users.schema.js        ← Joi/Zod validation schemas
      users.test.js
    products/
      products.router.js
      products.controller.js
      products.service.js
      products.schema.js
      products.test.js
    orders/
      orders.router.js
      orders.controller.js
      orders.service.js
      orders.schema.js
      orders.test.js
  middleware/
    auth.js
    errorHandler.js
    requestId.js
    validate.js
  config/
    db.js
    env.js
  app.js
  server.js

Everything related to users lives in features/users/. Adding a feature means adding one folder. Deleting a feature means deleting one folder. No cross-cutting scars.


Implementing the Pattern — Users Feature

1. Schema (Validation)

js
// features/users/users.schema.js
import Joi from 'joi';

export const createUserSchema = Joi.object({
  name: Joi.string().min(2).max(100).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  role: Joi.string().valid('user', 'admin').default('user'),
});

export const updateUserSchema = Joi.object({
  name: Joi.string().min(2).max(100),
  email: Joi.string().email(),
}).min(1); // at least one field required

2. Service (Business Logic)

js
// features/users/users.service.js
import { User } from './users.model.js';
import { hashPassword } from '../../lib/crypto.js';
import { AppError } from '../../lib/errors.js';

export async function createUser(data) {
  const exists = await User.findOne({ email: data.email });
  if (exists) throw new AppError('Email already registered', 409);

  const hashed = await hashPassword(data.password);
  const user = await User.create({ ...data, password: hashed });
  return sanitize(user);
}

export async function getUserById(id) {
  const user = await User.findById(id);
  if (!user) throw new AppError('User not found', 404);
  return sanitize(user);
}

export async function listUsers({ page = 1, limit = 20 } = {}) {
  const skip = (page - 1) * limit;
  const [users, total] = await Promise.all([
    User.find().skip(skip).limit(limit).lean(),
    User.countDocuments(),
  ]);
  return { users: users.map(sanitize), total, page, limit };
}

export async function updateUser(id, data) {
  const user = await User.findByIdAndUpdate(id, data, { new: true, runValidators: true });
  if (!user) throw new AppError('User not found', 404);
  return sanitize(user);
}

export async function deleteUser(id) {
  const user = await User.findByIdAndDelete(id);
  if (!user) throw new AppError('User not found', 404);
}

// Never return the password field
function sanitize(user) {
  const { password, __v, ...safe } = user.toObject ? user.toObject() : user;
  return safe;
}

3. Controller (HTTP Layer)

js
// features/users/users.controller.js
import * as usersService from './users.service.js';

export async function list(req, res, next) {
  try {
    const { page, limit } = req.query;
    const result = await usersService.listUsers({ page: +page, limit: +limit });
    res.json(result);
  } catch (err) {
    next(err);
  }
}

export async function getById(req, res, next) {
  try {
    const user = await usersService.getUserById(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
}

export async function create(req, res, next) {
  try {
    const user = await usersService.createUser(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
}

export async function update(req, res, next) {
  try {
    const user = await usersService.updateUser(req.params.id, req.body);
    res.json(user);
  } catch (err) {
    next(err);
  }
}

export async function remove(req, res, next) {
  try {
    await usersService.deleteUser(req.params.id);
    res.status(204).send();
  } catch (err) {
    next(err);
  }
}

Notice the controller's only job: parse req, call a service, write res. No business logic. No database queries. This makes controllers trivially readable and services independently testable.

4. Router

js
// features/users/users.router.js
import express from 'express';
import * as controller from './users.controller.js';
import { validate } from '../../middleware/validate.js';
import { requireAuth } from '../../middleware/auth.js';
import { requireRole } from '../../middleware/rbac.js';
import { createUserSchema, updateUserSchema } from './users.schema.js';

const router = express.Router();

// GET /api/users
router.get('/', requireAuth, controller.list);

// GET /api/users/:id
router.get('/:id', requireAuth, controller.getById);

// POST /api/users
router.post('/', requireAuth, requireRole('admin'), validate(createUserSchema), controller.create);

// PUT /api/users/:id
router.put('/:id', requireAuth, requireRole('admin'), validate(updateUserSchema), controller.update);

// DELETE /api/users/:id
router.delete('/:id', requireAuth, requireRole('admin'), controller.remove);

export default router;

Each route lists exactly what it needs: auth check, role check, validation, then the controller. Reading it tells you the complete story of each endpoint.


Wiring Up app.js

js
// app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { requestId } from './middleware/requestId.js';
import { requestLogger } from './middleware/logger.js';
import { errorHandler } from './middleware/errorHandler.js';
import { notFound } from './middleware/notFound.js';

// Feature routers
import authRouter from './features/auth/auth.router.js';
import usersRouter from './features/users/users.router.js';
import productsRouter from './features/products/products.router.js';
import ordersRouter from './features/orders/orders.router.js';

const app = express();

// ── Security & parsing ──────────────────────────────────────────────────────
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGIN }));
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// ── Observability ───────────────────────────────────────────────────────────
app.use(requestId);
app.use(requestLogger);

// ── Health check (no auth) ──────────────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok' }));

// ── Feature routes ──────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/users', usersRouter);
app.use('/api/v1/products', productsRouter);
app.use('/api/v1/orders', ordersRouter);

// ── 404 & error handling ────────────────────────────────────────────────────
app.use(notFound);
app.use(errorHandler);

export default app;
js
// server.js — separated from app.js
import app from './app.js';
import { connectDB } from './config/db.js';

const PORT = process.env.PORT || 3000;

async function start() {
  await connectDB();
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
}

start().catch(console.error);

Separating app.js from server.js is a small but important decision. Tests can import app from './app.js' and pass it to Supertest without starting the HTTP server. The server only starts when server.js runs.


The notFound and errorHandler Middleware

js
// middleware/notFound.js
export function notFound(req, res, next) {
  const error = new Error(`Not found — ${req.method} ${req.originalUrl}`);
  error.status = 404;
  next(error);
}
js
// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  // Don't leak stack traces in production
  const body = {
    status: 'error',
    message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  };

  if (status >= 500) {
    console.error({ requestId: req.id, err });
  }

  res.status(status).json(body);
}
js
// lib/errors.js — a base error class with HTTP status
export class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.statusCode = statusCode;
    this.status = statusCode;
    Error.captureStackTrace(this, this.constructor);
  }
}

With AppError, services can throw new AppError('Not found', 404) and the global handler converts it to the right HTTP response. No try/catch ladder in routes, no scattered res.status(404).json(...) calls.


Nested Routers

For deeply nested resources (e.g., /api/orders/:orderId/items), you can nest routers:

js
// features/orders/items.router.js
import express from 'express';
const router = express.Router({ mergeParams: true }); // ← important!

router.get('/', async (req, res, next) => {
  try {
    // req.params.orderId is available because of mergeParams: true
    const items = await itemsService.listByOrder(req.params.orderId);
    res.json(items);
  } catch (err) {
    next(err);
  }
});

export default router;
js
// features/orders/orders.router.js
import express from 'express';
import itemsRouter from './items.router.js';

const router = express.Router();

router.use('/:orderId/items', itemsRouter);

router.get('/', controller.list);
router.get('/:orderId', controller.getById);

export default router;

The mergeParams: true option is critical — without it, req.params.orderId is undefined inside the nested router because Express resets params at each router boundary.


Route Grouping with a Router Index

For features with many sub-routers, create an index that wires them together:

js
// features/index.js
import express from 'express';
import authRouter from './auth/auth.router.js';
import usersRouter from './users/users.router.js';
import productsRouter from './products/products.router.js';
import ordersRouter from './orders/orders.router.js';

const router = express.Router();

router.use('/auth', authRouter);
router.use('/users', usersRouter);
router.use('/products', productsRouter);
router.use('/orders', ordersRouter);

export default router;
js
// app.js — one import instead of four
import featuresRouter from './features/index.js';
app.use('/api/v1', featuresRouter);

Now app.js doesn't need to know about individual features. Adding a new feature means creating a folder and registering it in features/index.js.


Validate Middleware (Reusable)

js
// middleware/validate.js
export function validate(schema, property = 'body') {
  return (req, res, next) => {
    const { error, value } = schema.validate(req[property], {
      abortEarly: false,
      stripUnknown: true,
    });

    if (error) {
      const details = error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message,
      }));
      return res.status(422).json({ status: 'error', errors: details });
    }

    req[property] = value; // replace with sanitised value
    next();
  };
}

Usage in any router:

js
router.post('/', validate(createUserSchema), controller.create);
router.get('/', validate(listQuerySchema, 'query'), controller.list);
router.put('/:id', validate(updateUserSchema), controller.update);

One middleware, any schema, any request property.


Complete File Tree

The final structure of a production Express application using this pattern:

text
src/
├── app.js                         ← Express app (no server.listen)
├── server.js                      ← Starts HTTP server
├── config/
│   ├── db.js                      ← DB connection
│   └── env.js                     ← Validated env vars
├── features/
│   ├── index.js                   ← Mounts all feature routers
│   ├── auth/
│   │   ├── auth.router.js
│   │   ├── auth.controller.js
│   │   ├── auth.service.js
│   │   └── auth.schema.js
│   ├── users/
│   │   ├── users.router.js
│   │   ├── users.controller.js
│   │   ├── users.service.js
│   │   ├── users.model.js
│   │   ├── users.schema.js
│   │   └── users.test.js
│   └── products/
│       ├── products.router.js
│       ├── products.controller.js
│       ├── products.service.js
│       ├── products.model.js
│       ├── products.schema.js
│       └── products.test.js
├── middleware/
│   ├── auth.js
│   ├── errorHandler.js
│   ├── notFound.js
│   ├── rbac.js
│   ├── requestId.js
│   ├── requestLogger.js
│   └── validate.js
└── lib/
    ├── crypto.js                  ← hashPassword, comparePassword
    └── errors.js                  ← AppError class

Node.js Full‑Stack Course — Module 13 of 32

You can now structure any Express application for long-term maintainability. Continue to Module 14 to connect your API to MongoDB using Mongoose.


    Summary

    Express Router is the key to a maintainable Node.js API:

    • express.Router() creates a mountable mini-application — wire it into app.use('/prefix', router)
    • Feature-based folder structure keeps all related code together and makes features independently deletable
    • The three-layer pattern — router → controller → service — separates HTTP concerns from business logic
    • mergeParams: true passes parent route params to nested routers
    • A global errorHandler middleware (4 params) catches everything forwarded via next(err)
    • Separate app.js from server.js so tests can import the app without starting an HTTP server

    Continue to Module 14: MongoDB & Mongoose — CRUD Operations →