Express Modular Architecture
Introduction
As your Express.js applications grow in complexity, managing all your code in a single file becomes impractical and difficult to maintain. This is where modular architecture comes into play. A modular architecture helps you organize your code into logical, reusable components that are easier to maintain, test, and scale.
In this guide, we'll explore how to structure an Express application using modular patterns, breaking down your application into different components such as routes, controllers, models, and services. This approach will help you build applications that are not only easier to maintain but also easier to extend as your project requirements evolve.
Why Use Modular Architecture?
Before diving into the implementation details, let's understand the benefits of using a modular architecture:
- Maintainability: Breaking your application into smaller, focused modules makes it easier to understand and maintain.
- Reusability: Modules can be reused across different parts of your application or even in different projects.
- Testability: Smaller, isolated modules are easier to test.
- Collaboration: Different team members can work on different modules simultaneously.
- Scalability: A modular codebase allows your application to grow without becoming unmanageable.
Basic Folder Structure
Let's start by creating a basic folder structure for our Express application:
project-root/
├── src/
│ ├── config/ # Configuration files
│ ├── controllers/ # Request handlers
│ ├── models/ # Data models
│ ├── routes/ # Route definitions
│ ├── services/ # Business logic
│ ├── middlewares/ # Custom middleware functions
│ ├── utils/ # Utility functions
│ └── app.js # Express app setup
├── tests/ # Test files
├── .env # Environment variables
├── .gitignore
├── package.json
└── README.md
This structure separates concerns and makes it easy to locate different parts of your application.
Setting Up the Main Application File
Let's start by creating our main application file. In src/app.js
:
const express = require('express');
const path = require('path');
const morgan = require('morgan');
// Import routes
const userRoutes = require('./routes/userRoutes');
const productRoutes = require('./routes/productRoutes');
// Create Express app
const app = express();
// Middlewares
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
status: 'error',
message: 'Something went wrong!'
});
});
module.exports = app;
And then create a server.js
file in the project root to start the server:
const app = require('./src/app');
const config = require('./src/config/config');
const PORT = config.port || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Implementing Routes
Routes define the endpoints of your API and delegate the business logic to controllers. Let's implement a route module for users in src/routes/userRoutes.js
:
const express = require('express');
const userController = require('../controllers/userController');
const authMiddleware = require('../middlewares/authMiddleware');
const router = express.Router();
// Public routes
router.post('/register', userController.register);
router.post('/login', userController.login);
// Protected routes
router.use(authMiddleware.protect);
router.get('/profile', userController.getProfile);
router.put('/profile', userController.updateProfile);
// Admin routes
router.use(authMiddleware.restrictTo('admin'));
router.get('/', userController.getAllUsers);
router.delete('/:id', userController.deleteUser);
module.exports = router;
Implementing Controllers
Controllers handle the request-response cycle and interact with services to perform business logic. Here's an example of a user controller in src/controllers/userController.js
:
const userService = require('../services/userService');
exports.register = async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json({
status: 'success',
data: {
user
}
});
} catch (err) {
next(err);
}
};
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
const token = await userService.loginUser(email, password);
res.status(200).json({
status: 'success',
data: {
token
}
});
} catch (err) {
next(err);
}
};
exports.getProfile = async (req, res, next) => {
try {
res.status(200).json({
status: 'success',
data: {
user: req.user
}
});
} catch (err) {
next(err);
}
};
// More controller methods...
Creating Service Layers
Services contain the business logic of your application. They interact with models and other services but not directly with the request or response objects. Here's an example of a user service in src/services/userService.js
:
const User = require('../models/userModel');
const jwt = require('jsonwebtoken');
const config = require('../config/config');
const AppError = require('../utils/appError');
exports.createUser = async (userData) => {
// Validate user data
if (!userData.email || !userData.password) {
throw new AppError('Please provide email and password', 400);
}
// Check if user already exists
const existingUser = await User.findOne({ email: userData.email });
if (existingUser) {
throw new AppError('User with this email already exists', 400);
}
// Create new user
const user = await User.create({
name: userData.name,
email: userData.email,
password: userData.password
});
// Don't send password in response
user.password = undefined;
return user;
};
exports.loginUser = async (email, password) => {
// Find user by email
const user = await User.findOne({ email }).select('+password');
if (!user) {
throw new AppError('Invalid email or password', 401);
}
// Check if password is correct
const isPasswordCorrect = await user.comparePassword(password);
if (!isPasswordCorrect) {
throw new AppError('Invalid email or password', 401);
}
// Generate JWT token
const token = jwt.sign({ id: user._id }, config.jwtSecret, {
expiresIn: config.jwtExpiresIn
});
return token;
};
// More service methods...
Implementing Models
Models define the data structure and provide methods to interact with your database. Here's an example using Mongoose for MongoDB in src/models/userModel.js
:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Please provide your name']
},
email: {
type: String,
required: [true, 'Please provide your email'],
unique: true,
lowercase: true
},
password: {
type: String,
required: [true, 'Please provide a password'],
minlength: 8,
select: false
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
createdAt: {
type: Date,
default: Date.now
}
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Method to compare passwords
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
const User = mongoose.model('User', userSchema);
module.exports = User;
Creating Custom Middlewares
Middlewares are functions that have access to the request and response objects and can execute code, modify the request/response, or end the request-response cycle. Here's an example authentication middleware in src/middlewares/authMiddleware.js
:
const jwt = require('jsonwebtoken');
const { promisify } = require('util');
const User = require('../models/userModel');
const config = require('../config/config');
const AppError = require('../utils/appError');
exports.protect = async (req, res, next) => {
try {
// Check if token exists
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
return next(new AppError('You are not logged in. Please log in to get access', 401));
}
// Verify token
const decoded = await promisify(jwt.verify)(token, config.jwtSecret);
// Check if user still exists
const user = await User.findById(decoded.id);
if (!user) {
return next(new AppError('The user belonging to this token no longer exists', 401));
}
// Grant access
req.user = user;
next();
} catch (err) {
next(new AppError('Authentication failed', 401));
}
};
exports.restrictTo = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(new AppError('You do not have permission to perform this action', 403));
}
next();
};
};
Configuration Management
Centralize your configuration in a dedicated module to make it easier to manage environment-specific settings. Create src/config/config.js
:
require('dotenv').config();
module.exports = {
nodeEnv: process.env.NODE_ENV || 'development',
port: process.env.PORT || 3000,
databaseURL: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '90d'
};
Error Handling Utility
Create a utility for handling errors consistently across your application in src/utils/appError.js
:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
Real-World Example: Building a Todo API
Let's put everything together and build a simple Todo API:
- Model (
src/models/todoModel.js
):
const mongoose = require('mongoose');
const todoSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'A todo must have a title']
},
description: {
type: String
},
completed: {
type: Boolean,
default: false
},
user: {
type: mongoose.Schema.ObjectId,
ref: 'User',
required: [true, 'A todo must belong to a user']
},
createdAt: {
type: Date,
default: Date.now
}
});
const Todo = mongoose.model('Todo', todoSchema);
module.exports = Todo;
- Service (
src/services/todoService.js
):
const Todo = require('../models/todoModel');
const AppError = require('../utils/appError');
exports.getAllTodos = async (userId) => {
return await Todo.find({ user: userId });
};
exports.createTodo = async (todoData, userId) => {
return await Todo.create({
title: todoData.title,
description: todoData.description,
user: userId
});
};
exports.getTodoById = async (id, userId) => {
const todo = await Todo.findOne({ _id: id, user: userId });
if (!todo) {
throw new AppError('No todo found with that ID', 404);
}
return todo;
};
exports.updateTodo = async (id, userId, updatedData) => {
const todo = await Todo.findOneAndUpdate(
{ _id: id, user: userId },
updatedData,
{ new: true, runValidators: true }
);
if (!todo) {
throw new AppError('No todo found with that ID', 404);
}
return todo;
};
exports.deleteTodo = async (id, userId) => {
const todo = await Todo.findOneAndDelete({ _id: id, user: userId });
if (!todo) {
throw new AppError('No todo found with that ID', 404);
}
return todo;
};
- Controller (
src/controllers/todoController.js
):
const todoService = require('../services/todoService');
exports.getAllTodos = async (req, res, next) => {
try {
const todos = await todoService.getAllTodos(req.user._id);
res.status(200).json({
status: 'success',
results: todos.length,
data: {
todos
}
});
} catch (err) {
next(err);
}
};
exports.createTodo = async (req, res, next) => {
try {
const todo = await todoService.createTodo(req.body, req.user._id);
res.status(201).json({
status: 'success',
data: {
todo
}
});
} catch (err) {
next(err);
}
};
exports.getTodo = async (req, res, next) => {
try {
const todo = await todoService.getTodoById(req.params.id, req.user._id);
res.status(200).json({
status: 'success',
data: {
todo
}
});
} catch (err) {
next(err);
}
};
exports.updateTodo = async (req, res, next) => {
try {
const todo = await todoService.updateTodo(req.params.id, req.user._id, req.body);
res.status(200).json({
status: 'success',
data: {
todo
}
});
} catch (err) {
next(err);
}
};
exports.deleteTodo = async (req, res, next) => {
try {
await todoService.deleteTodo(req.params.id, req.user._id);
res.status(204).json({
status: 'success',
data: null
});
} catch (err) {
next(err);
}
};
- Route (
src/routes/todoRoutes.js
):
const express = require('express');
const todoController = require('../controllers/todoController');
const authMiddleware = require('../middlewares/authMiddleware');
const router = express.Router();
// Protect all todo routes
router.use(authMiddleware.protect);
router
.route('/')
.get(todoController.getAllTodos)
.post(todoController.createTodo);
router
.route('/:id')
.get(todoController.getTodo)
.patch(todoController.updateTodo)
.delete(todoController.deleteTodo);
module.exports = router;
- Add todo routes to
app.js
:
// Add this to your existing app.js
const todoRoutes = require('./routes/todoRoutes');
app.use('/api/todos', todoRoutes);
Testing the API
Here's how you can test the Todo API using tools like Postman or curl:
- Register a new user:
curl -X POST http://localhost:3000/api/users/register \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "[email protected]", "password": "password123"}'
- Login to get a token:
curl -X POST http://localhost:3000/api/users/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
- Create a new todo:
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"title": "Learn Express Modular Architecture", "description": "Study modular patterns for Express.js applications"}'
- Get all todos:
curl -X GET http://localhost:3000/api/todos \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
Best Practices for Modular Architecture
- Keep modules focused: Each module should have a single responsibility.
- Use dependency injection: Pass dependencies to modules rather than importing them directly.
- Avoid circular dependencies: Structure your imports to prevent modules from depending on each other circularly.
- Consistent error handling: Use a centralized approach to error handling throughout your application.
- Documentation: Document the purpose of each module and how modules interact with each other.
- Testing: Write tests for individual modules to ensure they work correctly in isolation.
Summary
In this guide, we've explored how to implement a modular architecture in Express.js applications, covering:
- Creating a structured folder organization
- Separating concerns with routes, controllers, services, and models
- Implementing middleware for authentication and authorization
- Configuring the application with environment variables
- Handling errors consistently across the application
- Building a complete Todo API as a real-world example
By adopting a modular approach, you can build Express.js applications that are easier to maintain, test, and scale. This architecture provides a solid foundation for growing your application while keeping your codebase organized and manageable.
Additional Resources and Exercises
Resources:
Exercises:
- Extend the Todo API: Add features like categories, due dates, and priority levels.
- Implement Pagination: Add pagination to the
getAllTodos
endpoint. - Add Validation: Use a library like Joi or express-validator to validate incoming requests.
- Implement Logging: Add a logging module to track application events and errors.
- Create Documentation: Use Swagger or another tool to document your API.
By practicing these exercises, you'll gain hands-on experience with modular architecture and improve your Express.js development skills.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)