Building Modular Express APIs With Routers and Services
Modular APIs are easier to grow and test. Here is how to build them with routers and services.
Building Modular Express APIs With Routers and Services
Modular APIs are easier to grow and test. Here is how to build them with routers and services.
The Module Pattern
Each feature is a module with its own router, controller, service, model, and validations:
src/
modules/
users/
users.router.js
users.controller.js
users.service.js
users.model.js
users.validations.js
posts/
posts.router.js
posts.controller.js
posts.service.js
posts.model.js
posts.validations.js
app.js
The Module's Router
// modules/users/users.router.js
const express = require('express');
const router = express.Router();
const auth = require('../../middlewares/auth');
const validate = require('../../middlewares/validate');
const { createUserSchema, updateUserSchema } = require('./users.validations');
const controller = require('./users.controller');
router.get('/', auth, controller.listUsers);
router.post('/', validate(createUserSchema), controller.createUser);
router.get('/:id', controller.getUser);
router.patch('/:id', auth, validate(updateUserSchema), controller.updateUser);
router.delete('/:id', auth, controller.deleteUser);
module.exports = router;
The Module's Controller
// modules/users/users.controller.js
const service = require('./users.service');
const asyncHandler = require('../../utils/asyncHandler');
exports.listUsers = asyncHandler(async (req, res) => {
const users = await service.listUsers(req.query);
res.json(users);
});
exports.createUser = asyncHandler(async (req, res) => {
const user = await service.createUser(req.body);
res.status(201).json(user);
});
The Module's Service
// modules/users/users.service.js
const User = require('./users.model');
const ApiError = require('../../utils/ApiError');
const bcrypt = require('bcrypt');
async function createUser(data) {
const existing = await User.findOne({ email: data.email });
if (existing) throw new ApiError(409, 'Email already in use');
const passwordHash = await bcrypt.hash(data.password, 10);
return User.create({ ...data, passwordHash });
}
async function listUsers({ skip = 0, limit = 20 }) {
return User.find().skip(parseInt(skip)).limit(Math.min(parseInt(limit), 100)).lean();
}
module.exports = { createUser, listUsers };
Mounting Modules in app.js
app.use('/api/v1/users', require('./modules/users/users.router'));
app.use('/api/v1/posts', require('./modules/posts/posts.router'));
Why Modules
- Cohesion: everything for a feature is in one folder.
- Independent: a module can be extracted into a microservice later.
- Testable: test the service without HTTP context.
- Team scalability: different teams own different modules.
- Reusability: a module can be reused across apps.
When to Use the Module Pattern
For medium to large apps. For tiny apps, a flat structure (routes/, controllers/, models/) is fine. The module pattern adds folder nesting that is not worth it for 3 routes.
The Takeaway
Build modular Express APIs by grouping each feature into a module (router, controller, service, model, validations). Mount modules in app.js. Modules give cohesion, independence (extract to microservice later), testability, team scalability, and reusability. Use for medium to large apps; flat is fine for tiny.
Group each feature into a module: router, controller, service, model, validations in one folder (src/modules/users/). Mount modules in app.js. Each module is cohesive, independent, testable, and reusable.
Cohesion (everything for a feature in one folder), independence (a module can be extracted into a microservice later), testability (test the service without HTTP context), team scalability (different teams own different modules), and reusability across apps.
For medium to large apps. For tiny apps with 3 routes, a flat structure (routes/, controllers/, models/) is fine. The module pattern adds folder nesting that is not worth it for tiny apps.
Business logic. Functions that use the model and utilities to do work. createUser hashes the password, checks for duplicates, and creates the user. Services are reusable and testable without an HTTP context.
app.use('/api/v1/users', require('./modules/users/users.router')). Each module exports its router. Mount under a versioned path. app.js stays thin: global middlewares, module mounts, error handler.
Ready to master Node.js completely?
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.
Master Node.js
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course.

