Why routing architecture matters
As an Express codebase grows, routing can become the accidental “center of gravity”: every new feature adds another endpoint, another middleware, another conditional. A routing architecture keeps the routing layer focused on mapping (URL + HTTP method + middleware stack) and keeps request handling inside controllers/services. The goal is clear boundaries: route files stay declarative, controllers stay imperative, and domain modules stay isolated.
What “clear boundaries” means in practice
- Routes: define paths, method, and middleware stack; call a controller function.
- Controllers: translate HTTP to domain calls (read params/body, call services, shape response).
- Services/use-cases: business logic; no Express objects.
- Infrastructure: database clients, queues, external APIs.
This chapter focuses on the routing layer: how to build a routing tree with Express.Router, group by domain/resource/version, and keep route files from turning into controller logic.
Build a routing tree with per-domain modules
A routing tree is a hierarchy of routers mounted at prefixes. Each domain module exports a router, and the top-level API router composes them. This keeps boundaries explicit and prevents “one giant routes file”.
Suggested folder layout
src/ app.js api/ index.js # mounts versions v1/ index.js # mounts domain routers users/ routes.js controller.js projects/ routes.js controller.js v2/ index.js users/ routes.js controller.js shared/ http/ errors.js asyncHandler.js middlewares/ auth.js validate.js rateLimit.jsKey idea: routes.js should read like a table of endpoints. Anything that looks like “if/else”, “try/catch”, “mapping DTOs”, or “query building” belongs in controllers/services.
Step-by-step: compose routers
1) Create a version router that mounts domain routers:
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
// src/api/v1/index.jsconst express = require('express');const usersRoutes = require('./users/routes');const projectsRoutes = require('./projects/routes');const router = express.Router();router.use('/users', usersRoutes);router.use('/projects', projectsRoutes);module.exports = router;2) Create the API root router that mounts versions:
// src/api/index.jsconst express = require('express');const v1 = require('./v1');const v2 = require('./v2');const router = express.Router();router.use('/v1', v1);router.use('/v2', v2);module.exports = router;3) Mount the API router in the app:
// src/app.jsconst express = require('express');const api = require('./api');const app = express();app.use(express.json());app.use('/api', api);module.exports = app;This structure makes it easy to add a new domain (billing) or a new version (v3) without touching unrelated modules.
Group routes by resource and version (without mixing in controller logic)
Within a domain, group routes by resource. Typically, a domain router corresponds to a top-level resource (/users, /projects). If a domain contains sub-resources, keep them as nested routers or explicit subpaths, but still avoid embedding logic in the route file.
Example: users routes as a declarative map
// src/api/v1/users/routes.jsconst express = require('express');const controller = require('./controller');const { requireAuth } = require('../../../shared/middlewares/auth');const { validate } = require('../../../shared/middlewares/validate');const { rateLimit } = require('../../../shared/middlewares/rateLimit');const { asyncHandler } = require('../../../shared/http/asyncHandler');const router = express.Router();const createUserRules = { body: { email: 'email|required', name: 'string|required' } };const updateUserRules = { body: { name: 'string|optional' } };router.get('/', requireAuth, rateLimit('users:list'), asyncHandler(controller.list));router.post('/', requireAuth, rateLimit('users:create'), validate(createUserRules), asyncHandler(controller.create));router.get('/:userId', requireAuth, rateLimit('users:get'), asyncHandler(controller.getById));router.patch('/:userId', requireAuth, rateLimit('users:update'), validate(updateUserRules), asyncHandler(controller.update));router.delete('/:userId', requireAuth, rateLimit('users:delete'), asyncHandler(controller.remove));module.exports = router;Notice what is not here: no parsing of query filters, no database calls, no response shaping beyond delegating to the controller. The route file is a list of endpoints with consistent middleware stacks.
Controllers encapsulate request handling
// src/api/v1/users/controller.jsconst usersService = require('./usersService');exports.list = async (req, res) => { const { page = 1, pageSize = 20 } = req.query; const result = await usersService.list({ page: Number(page), pageSize: Number(pageSize) }); res.json(result);};exports.create = async (req, res) => { const user = await usersService.create(req.body); res.status(201).json(user);};exports.getById = async (req, res) => { const user = await usersService.getById(req.params.userId); res.json(user);};exports.update = async (req, res) => { const user = await usersService.update(req.params.userId, req.body); res.json(user);};exports.remove = async (req, res) => { await usersService.remove(req.params.userId); res.status(204).send();};The controller is allowed to read req and write res. If you find yourself repeating the same parsing/formatting across controllers, that’s a sign to introduce shared helpers (e.g., pagination parsing), but keep them out of route files.
Route definition conventions that scale
Conventions reduce cognitive load. When every router follows the same patterns, you can navigate quickly and avoid subtle inconsistencies.
HTTP method semantics
| Method | Use for | Notes |
|---|---|---|
GET | Read resources | Should not change server state; safe to retry. |
POST | Create resources or trigger non-idempotent actions | Returns 201 for creation; include Location when appropriate. |
PUT | Replace a resource | Idempotent; client sends full representation. |
PATCH | Partial update | Idempotent if designed carefully; send only changed fields. |
DELETE | Remove a resource | Often returns 204 with empty body. |
Keep “action endpoints” rare. Prefer modeling actions as resources when possible. If you must use an action, make it explicit and consistent (e.g., POST /projects/:id/archive).
Consistent URL patterns
- Plural nouns for collections:
/users,/projects. - Path params for identifiers:
/users/:userId. - Sub-resources for ownership:
/projects/:projectId/members. - Query params for filtering/sorting/pagination:
GET /users?role=admin&page=2. - Versioning at a stable prefix:
/api/v1,/api/v2.
Choose parameter names that match the resource (:userId, not :id everywhere) to improve readability in logs and code.
Route-level middleware stacks: make them readable
Each route has a middleware stack. When stacks get long, readability suffers. A scalable approach is to build stacks from reusable arrays (or functions returning arrays) and keep the route definition as a single line per endpoint.
Extract shared concerns into reusable arrays
Common concerns include authentication, authorization, validation, and rate limiting. Instead of repeating them inline, define composable stacks.
// src/api/v1/users/routeStacks.jsconst { requireAuth } = require('../../../shared/middlewares/auth');const { rateLimit } = require('../../../shared/middlewares/rateLimit');const { validate } = require('../../../shared/middlewares/validate');exports.stacks = { list: [requireAuth, rateLimit('users:list')], create: [requireAuth, rateLimit('users:create'), validate({ body: { email: 'email|required', name: 'string|required' } })], getById: [requireAuth, rateLimit('users:get')], update: [requireAuth, rateLimit('users:update'), validate({ body: { name: 'string|optional' } })], remove: [requireAuth, rateLimit('users:delete')],};Then your routes become highly declarative:
// src/api/v1/users/routes.jsconst express = require('express');const controller = require('./controller');const { asyncHandler } = require('../../../shared/http/asyncHandler');const { stacks } = require('./routeStacks');const router = express.Router();router.get('/', ...stacks.list, asyncHandler(controller.list));router.post('/', ...stacks.create, asyncHandler(controller.create));router.get('/:userId', ...stacks.getById, asyncHandler(controller.getById));router.patch('/:userId', ...stacks.update, asyncHandler(controller.update));router.delete('/:userId', ...stacks.remove, asyncHandler(controller.remove));module.exports = router;This pattern makes it easy to review security and validation at a glance: you can inspect routeStacks.js and see what each endpoint enforces.
When to use stack factories
If stacks depend on parameters (e.g., different permissions per route), use a factory function:
// src/shared/http/stacks.jsconst { requireAuth } = require('../middlewares/auth');const { requirePermission } = require('../middlewares/authz');exports.authz = (permission) => [requireAuth, requirePermission(permission)];// in a routes fileconst { authz } = require('../../shared/http/stacks');router.post('/', ...authz('projects:create'), asyncHandler(controller.create));Keep route files declarative: anti-patterns to avoid
Anti-pattern: route files doing controller work
If you see any of the following inside routes.js, it’s a sign boundaries are slipping:
- Building database queries
- Mapping request bodies into domain objects
- Complex branching based on headers or roles
- Formatting response payloads
- Inline
try/catchor error translation
Instead, route files should only assemble the middleware pipeline and delegate to controllers.
Anti-pattern: inconsistent middleware ordering
Even if you have shared middleware, inconsistent ordering can cause subtle bugs (e.g., rate limiting before auth vs after). Establish a convention per route type. A common convention is:
- Auth (identify user)
- Authorization (check permissions)
- Rate limit (per user/tenant)
- Validation (body/query/params)
- Controller
Encode this convention into your stack helpers so it’s hard to get wrong.
Versioning without duplicating everything
When introducing v2, avoid copying entire routers unless the surface truly changes. Prefer sharing controllers/services and only branching where behavior differs.
Example: v2 users routes reusing v1 controller with a different stack
// src/api/v2/users/routes.jsconst express = require('express');const v1Controller = require('../../v1/users/controller');const { asyncHandler } = require('../../../shared/http/asyncHandler');const { requireAuth } = require('../../../shared/middlewares/auth');const { rateLimit } = require('../../../shared/middlewares/rateLimit');const router = express.Router();// v2 might change rate limit keys or add stricter defaultsrouter.get('/', requireAuth, rateLimit('v2:users:list'), asyncHandler(v1Controller.list));module.exports = router;If v2 changes response shape, create a v2 controller that calls the same service but formats output differently. Keep that formatting in the controller, not in the router.
Nested routers for sub-resources (members, comments, etc.)
For sub-resources, nested routers can keep files small and boundaries clear. Use mergeParams: true so the child router can access parent params.
// src/api/v1/projects/members/routes.jsconst express = require('express');const controller = require('./controller');const { asyncHandler } = require('../../../../shared/http/asyncHandler');const { requireAuth } = require('../../../../shared/middlewares/auth');const router = express.Router({ mergeParams: true });router.get('/', requireAuth, asyncHandler(controller.list));router.post('/', requireAuth, asyncHandler(controller.add));router.delete('/:memberId', requireAuth, asyncHandler(controller.remove));module.exports = router;// src/api/v1/projects/routes.jsconst express = require('express');const controller = require('./controller');const membersRoutes = require('./members/routes');const { asyncHandler } = require('../../../shared/http/asyncHandler');const { requireAuth } = require('../../../shared/middlewares/auth');const router = express.Router();router.get('/', requireAuth, asyncHandler(controller.list));router.post('/', requireAuth, asyncHandler(controller.create));router.use('/:projectId/members', membersRoutes);module.exports = router;This keeps the projects router focused on project endpoints while delegating member-specific endpoints to a dedicated module.
Practical checklist for reviewing a router module
- Is the router mounted under a clear prefix (
/api/v1, then/users)? - Does each route read like:
METHOD path + middleware stack + controller? - Are URL patterns consistent (plural resources, meaningful param names)?
- Are middleware stacks composed from reusable arrays/factories for shared concerns?
- Is there any business logic in the route file that should move to a controller/service?
- Are sub-resources handled via nested routers when they grow beyond a few endpoints?