Designing Modular Routing with Express.Router in Node.js Microservices
Learn how to structure Express.js microservices using Express.Router to isolate concerns, enforce middleware boundaries, and keep the main app file maintainable.
15 Jun 2026, 10:47 UTC

Requirements
In a Node.js microservice you need a routing layer that keeps the main application file readable, isolates concerns per domain, and allows reusable middleware without affecting unrelated endpoints. The solution must support async handlers, proper error propagation, and straightforward testing.
Smallest Suitable Design
The minimal design uses one express.Router instance per logical domain (e.g., users, orders). Each router owns its route definitions, domain‑specific middleware, and is exported as a module. The main app file only mounts the routers at distinct base paths.
Example Structure
// routers/users.js
const express = require('express');
const router = express.Router();
// domain‑specific middleware (runs only for this router)
router.use((req, res, next) => {
const token = req.headers['x-auth-token'];
if (!token) return res.status(401).send({ error: 'missing token' });
// placeholder validation – replace with real check
next();
});
router.get('/list', async (req, res) => {
// simulate data fetch
const users = [{ id: 1, name: 'alice' }, { id: 2, name: 'bob' }];
res.json(users);
});
module.exports = router;
// app.js
const express = require('express');
const usersRouter = require('./routers/users');
const app = express();
app.use(express.json());
// mount router at a base path
app.use('/api/users', usersRouter);
// global error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send({ error: 'internal error' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`listening on ${PORT}`));
Trust and Data Boundaries
Each router defines its own trust boundary:
- Middleware attached to a router runs only for routes under its mount point, preventing unintended side effects on other domains.
- Route paths are relative to the router; mounting at
/api/usersyields full paths like/api/users/list. - State that must be shared across domains (e.g., a database connection) should be attached to the
appobject or passed via dependency injection, not stored on the router itself.
Operational Checks
To verify the design works as intended:
- Start the service:
node app.js(requires read/execute permission on the file). - Check route availability:
curl -i http://localhost:3000/api/users/list. Expect a 200 response with JSON body. - Confirm middleware enforcement:
curl -i http://localhost:3000/api/users/list(no header) should return 401. - Validate isolation: Request a path outside the router, e.g.,
curl -i http://localhost:3000/api/orders. Expect 404 because no router is mounted at/api/orders. - Monitor logs: Ensure the router‑specific middleware logs appear only for calls under
/api/users.
Failure Modes
- Missing mount: If
app.use('/api/users', usersRouter)is omitted or placed after a conflictingapp.use('*', ...)catch‑all, requests to/api/users/*will hit the catch‑all and return an unintended response (often 404 or 500). - Middleware leakage: Accidentally using
app.useinside the router file (instead ofrouter.use) makes the middleware global, violating the trust boundary. - Unhandled async errors: Forgetting to pass errors to
next(err)in an async handler leaves the request hanging; the global error handler never runs. Wrap async route handlers in a utility that catches and forwards errors, or useexpress-async-errors. - Order‑dependent 404s: Defining a route on the router after mounting another router that captures the same base path can shadow it. Keep mount points distinct and load routers before any generic fallback handlers.
Conditions That Would Change the Design
Reevaluate the router‑per‑domain approach when:
- The service migrates to a serverless platform where each function exposes a single HTTP endpoint; routers add unnecessary indirection.
- You need to share request‑scoped state (e.g., a transaction object) across many domains; a custom middleware on
appmay be simpler than prop‑drilling through each router. - The API evolves toward a GraphQL or gRPC model; HTTP‑centric routing becomes less relevant.
- Performance profiling shows router middleware overhead is significant for high‑frequency micro‑endpoints; consolidating middleware at the app level could reduce function call depth.
Practical Verification Checklist
- Run the service and confirm the expected JSON payload for
GET /api/users/list. - Confirm that omitting the
x-auth-tokenheader yields a 401. - Confirm that a request to a non‑mounted prefix (e.g.,
/api/orders) returns 404. - Review logs to ensure router‑specific middleware appears only for calls under its mount.
- Introduce a deliberate error in an async handler (e.g.,
throw new Error('test')) and verify the global error handler logs and returns a 500.
If any of these checks fail, adjust the mount order, ensure middleware is attached to the router instance, and wrap async handlers with error‑forwarding logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.