Express.js / Intermediate: modules, identity and adapters / Lesson 7
Routers, mount paths and module boundaries
Compose a modular API while preserving parameter scope, middleware order and object authorization.
On this page
Lesson goals
- Distinguish a router-relative path from the original request URL.
- Use parent parameters deliberately and inject module dependencies.
A router is a scoped middleware composition
express.Router creates a mountable middleware and route system, not an independently listening server. Group routes around a domain capability rather than splitting files arbitrarily by HTTP method. A course module can own its input adapters and public result shapes while sharing an injected catalog policy. Mounting the router at /api/courses means its /:id route addresses that prefix plus its relative path. Define where authentication, body parsing, object policy and error translation live so a second mount cannot accidentally omit a prerequisite. Modularity works only when the module’s contract includes these dependencies.
Make parent parameter inheritance explicit
A child router does not automatically inherit every parent parameter. The mergeParams option preserves parent req.params values where the mounted design needs them; conflicting names favor the child value. The fragment uses distinct tenantId and courseId names to avoid that ambiguity. A tenant from the path identifies the requested scope but must still match the authenticated principal’s permission. The injected service enforces that decision. This fragment assumes app, express and catalog exist and intentionally neither imports a package nor starts a server.
function makeCourseRouter(express,catalog) {
const router = express.Router({mergeParams:true});
router.get("/:courseId", async (req,res) => {
const card = await catalog.visibleCard({
principal:req.principal, tenantId:req.params.tenantId, id:req.params.courseId
});
if (!card) return res.status(404).json({code:"COURSE_NOT_FOUND"});
return res.json(card);
});
return router;
}
app.use("/tenants/:tenantId/courses", makeCourseRouter(express,catalog));
mergeParams preserves tenantId; courseId is defined inside the child. The service still owns tenant/object eligibility.
Choose request URL properties for their documented purpose
Within a mounted router, req.baseUrl describes the matched mount path, req.originalUrl preserves the original request URL and req.path describes a path view without the query string. Do not combine them blindly or use the original URL as an authorization decision. Logging a full original URL can reveal private query values. Prefer a safe route identifier plus validated metadata for routine telemetry. Redirects and links also need a deliberate external-base policy, especially behind proxies; an internal mount path is not automatically a trusted public origin.
Check requested scope against principal scope
The complete local predicate models one prerequisite for the nested route: the principal belongs to the requested tenant. It does not implement object visibility or establish that the principal is authentic. Testing the same requested tenant with two principals makes the scope distinction visible without Express. A real policy can include delegated roles or membership rather than simple equality, but those exceptions need explicit rules. Do not accept a route’s tenant label as proof of membership, and do not store it in global mutable state where another request can overwrite it.
function scopeAllowed(principal,requestedTenant) {
return principal.tenantId === requestedTenant;
}
console.assert(scopeAllowed({tenantId:"lab-a"},"lab-a"));
console.assert(!scopeAllowed({tenantId:"lab-b"},"lab-a"));
The first caller passes and the second fails this local equality rule. Authentication and full catalog policy are not tested.
Make route-local and application-wide failure policy agree
A router can finish a local result or forward an unexpected failure to application error handling. Keep public codes consistent and avoid a catch-all inside the module that hides unrelated routes mounted later. Parameter middleware belongs to its documented router scope and is not a global validator for every similarly named parameter elsewhere. Test a router alone with injected fakes, then test its actual mount prefix and inherited parameters in integration. A passing local scope predicate cannot establish route matching, parser placement or final 404 ownership.
Common errors and fixes
tenantId disappears inside a child router
- Why it happens
- The design assumed parent parameters were inherited without mergeParams.
- How to fix it
- Use deliberate parameter inheritance and distinct names.
- Prevent it next time
- Test the mounted route with its actual parent prefix.
A tenant path bypasses access policy
- Why it happens
- Requested scope was confused with authorized scope.
- How to fix it
- Compare principal eligibility in the shared domain service.
- Prevent it next time
- Test another principal against the same path.
A module hides routes registered later
- Why it happens
- A broad local fallback consumed requests outside its responsibility.
- How to fix it
- Narrow the fallback or place unmatched handling at the intended owner.
- Prevent it next time
- Review matching and fallthrough at every mount.
Check your understanding
1. Does mergeParams authorize the parent tenant?
Show worked solution
No. It preserves parameter values for the child router. Those values remain request input. The domain service must compare requested scope with the authenticated principal and apply object policy. Test both missing inheritance and denied membership because they are different faults with different remedies.
2. What should a router factory receive instead of importing a configured global database?
Show worked solution
Receive a narrow domain service or adapter with documented methods and policy responsibilities. Tests can inject synthetic implementations without opening connections. A separate process owner assembles real dependencies and owns their lifetime. This preserves module reuse without accidentally starting services when the router is imported.
Key takeaways
- Router composition includes scope, prerequisites and fallthrough behavior.
- Inherited path values remain untrusted input.