Express Maintainability
Introduction
Building an Express application is one thing, but building one that remains maintainable as it grows is another challenge entirely. Maintainable code is code that can be easily understood, modified, and extended by you and other developers, both now and in the future.
In this guide, we'll explore essential strategies and best practices for creating maintainable Express.js applications. Whether you're working solo or in a team, these principles will help your project scale gracefully over time.
Why Maintainability Matters
Before diving into specific practices, let's understand why maintainability is critical:
- Future Development: Code is read far more often than it's written
- Collaboration: Makes it easier for team members to work together
- Onboarding: Helps new developers get up to speed quickly
- Troubleshooting: Makes debugging and fixing issues much faster
- Scalability: Enables your application to grow without becoming unwieldy
Project Structure Organization
Directory Structure
A well-organized directory structure makes navigation intuitive. Here's a recommended structure for Express applications:
project-root/
├── config/ # Configuration files
├── controllers/ # Route controllers
├── middlewares/ # Custom middleware functions
├── models/ # Data models
├── routes/ # Route definitions
├── services/ # Business logic
├── utils/ # Utility functions
├── views/ # Templates (if using server-side rendering)
├── public/ # Static assets
├── tests/ # Test files
├── app.js # Express app initialization
└── server.js # Server startup
Example Implementation
Let's see how to structure a basic user management feature:
- First, create the route file (
routes/userRoutes.js
):
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const authMiddleware = require('../middlewares/authMiddleware');
// Public routes
router.post('/register', userController.register);
router.post('/login', userController.login);
// Protected routes
router.get('/profile', authMiddleware.verifyToken, userController.getProfile);
router.put('/profile', authMiddleware.verifyToken, userController.updateProfile);
module.exports = router;
- Then, create the controller (
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({ success: true, data: user });
} catch (err) {
next(err);
}
};
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
const result = await userService.authenticateUser(email, password);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
exports.getProfile = async (req, res, next) => {
try {
const user = await userService.getUserById(req.user.id);
res.status(200).json({ success: true, data: user });
} catch (err) {
next(err);
}
};
exports.updateProfile = async (req, res, next) => {
try {
const updatedUser = await userService.updateUser(req.user.id, req.body);
res.status(200).json({ success: true, data: updatedUser });
} catch (err) {
next(err);
}
};
- Implementing the service layer (
services/userService.js
):
const User = require('../models/User');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const config = require('../config');
exports.createUser = async (userData) => {
// Check if user already exists
const existingUser = await User.findOne({ email: userData.email });
if (existingUser) {
throw new Error('User with this email already exists');
}
// Create new user
const user = new User(userData);
await user.save();
// Return user without password
const userObject = user.toObject();
delete userObject.password;
return userObject;
};
exports.authenticateUser = async (email, password) => {
// Find user by email
const user = await User.findOne({ email });
if (!user) {
throw new Error('User not found');
}
// Validate password
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
throw new Error('Invalid credentials');
}
// Generate JWT token
const token = jwt.sign({ id: user._id }, config.jwtSecret, { expiresIn: '1d' });
return {
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email
}
};
};
exports.getUserById = async (userId) => {
const user = await User.findById(userId).select('-password');
if (!user) {
throw new Error('User not found');
}
return user;
};
exports.updateUser = async (userId, updateData) => {
const user = await User.findByIdAndUpdate(
userId,
{ $set: updateData },
{ new: true }
).select('-password');
if (!user) {
throw new Error('User not found');
}
return user;
};
Modular Programming
Modular programming is key to maintainable Express applications. It involves breaking your code into smaller, focused modules that have a single responsibility.
The Single Responsibility Principle
Each module should have one job and do it well. For example:
- Controllers handle HTTP requests and responses
- Services contain business logic
- Models define data structures and database interactions
- Middleware process requests before they reach route handlers
Dependency Injection
Instead of hardcoding dependencies, pass them as parameters to make your code more testable and flexible:
// Before: Hardcoded dependency
const db = require('../database');
function getUserPosts(userId) {
return db.posts.findAll({ where: { userId } });
}
// After: Using dependency injection
function createUserService(db) {
return {
getUserPosts(userId) {
return db.posts.findAll({ where: { userId } });
}
};
}
const userService = createUserService(require('../database'));
Error Handling
Consistent error handling is crucial for maintainability and debugging.
Centralized Error Handling
Create a centralized error handler middleware to catch all errors:
// middlewares/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error(err.stack);
const statusCode = err.statusCode || 500;
const message = err.message || 'Something went wrong';
res.status(statusCode).json({
success: false,
error: {
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};
module.exports = errorHandler;
Register this middleware in your app.js after all routes:
// app.js
const express = require('express');
const errorHandler = require('./middlewares/errorHandler');
const app = express();
// Routes and other middleware
app.use('/api/users', require('./routes/userRoutes'));
// Error handling middleware (must be last!)
app.use(errorHandler);
Custom Error Classes
Create custom error classes for better error identification and handling:
// utils/errors.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 400);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized access') {
super(message, 401);
}
}
module.exports = {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError
};
Usage example:
const { NotFoundError } = require('../utils/errors');
exports.getUserById = async (userId) => {
const user = await User.findById(userId);
if (!user) {
throw new NotFoundError(`User with ID ${userId} not found`);
}
return user;
};
Configuration Management
Separate configuration from code to improve maintainability and security.
Environment Variables
Use environment variables for configuration that changes between environments (development, testing, production):
// config/index.js
require('dotenv').config();
module.exports = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
mongoUri: process.env.MONGO_URI || 'mongodb://localhost:27017/myapp',
jwtSecret: process.env.JWT_SECRET || 'your-secret-key', // Only for development
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1d',
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:3000'
};
Then in your .env
file (which should be in .gitignore):
PORT=8000
NODE_ENV=development
MONGO_URI=mongodb://localhost:27017/myapp
JWT_SECRET=super-secret-key-change-in-production
CORS_ORIGIN=http://localhost:3000
Documentation
Good documentation is essential for maintainability.
Code Comments
Comment your code where necessary, but focus on making the code self-explanatory:
/**
* Authenticates a user and returns a JWT token
*
* @param {string} email - User's email address
* @param {string} password - User's password
* @returns {Object} Authentication result with token and user data
* @throws {Error} If credentials are invalid
*/
exports.authenticateUser = async (email, password) => {
// Implementation...
};
API Documentation
Document your API endpoints using tools like Swagger/OpenAPI:
// app.js
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./swagger.json');
// API documentation route
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
Testing
Testable code is maintainable code.
Writing Testable Code
Follow these principles for testable Express code:
- Separation of concerns: Keep business logic separate from Express routes
- Dependency injection: Pass dependencies rather than requiring them directly
- Pure functions: When possible, use functions that return outputs based only on inputs
Testing Example
Here's a simple test for the user service using Jest:
// tests/services/userService.test.js
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const { authenticateUser } = require('../../services/userService');
const User = require('../../models/User');
// Mock dependencies
jest.mock('../../models/User');
jest.mock('bcrypt');
jest.mock('jsonwebtoken');
describe('User Service', () => {
describe('authenticateUser', () => {
it('should authenticate a valid user and return a token', async () => {
// Setup
const mockUser = {
_id: new mongoose.Types.ObjectId(),
name: 'Test User',
email: '[email protected]',
password: 'hashedPassword',
toObject: () => ({
_id: mockUser._id,
name: 'Test User',
email: '[email protected]',
password: 'hashedPassword'
})
};
// Mock implementations
User.findOne.mockResolvedValue(mockUser);
bcrypt.compare.mockResolvedValue(true);
jwt.sign.mockReturnValue('fake-token');
// Execute
const result = await authenticateUser('[email protected]', 'password123');
// Assert
expect(User.findOne).toHaveBeenCalledWith({ email: '[email protected]' });
expect(bcrypt.compare).toHaveBeenCalledWith('password123', 'hashedPassword');
expect(jwt.sign).toHaveBeenCalled();
expect(result).toEqual({
success: true,
token: 'fake-token',
user: expect.objectContaining({
id: mockUser._id,
name: mockUser.name,
email: mockUser.email
})
});
});
it('should throw an error if user is not found', async () => {
// Setup
User.findOne.mockResolvedValue(null);
// Execute & Assert
await expect(authenticateUser('[email protected]', 'password'))
.rejects.toThrow('User not found');
});
it('should throw an error if password is invalid', async () => {
// Setup
const mockUser = {
email: '[email protected]',
password: 'hashedPassword'
};
User.findOne.mockResolvedValue(mockUser);
bcrypt.compare.mockResolvedValue(false);
// Execute & Assert
await expect(authenticateUser('[email protected]', 'wrongpassword'))
.rejects.toThrow('Invalid credentials');
});
});
});
Logging
Proper logging is essential for monitoring and debugging.
Logging Setup with Winston
// utils/logger.js
const winston = require('winston');
const config = require('../config');
const logger = winston.createLogger({
level: config.nodeEnv === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'api-service' },
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' })
]
});
// If not in production, log to console as well
if (config.nodeEnv !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;
Logging Middleware
// middlewares/requestLogger.js
const logger = require('../utils/logger');
const requestLogger = (req, res, next) => {
// Log when request starts
logger.info(`${req.method} ${req.originalUrl}`, {
method: req.method,
url: req.originalUrl,
ip: req.ip,
userId: req.user?.id || 'unauthenticated'
});
// Log when request completes
const startTime = Date.now();
res.on('finish', () => {
const duration = Date.now() - startTime;
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, {
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
duration,
userId: req.user?.id || 'unauthenticated'
});
});
next();
};
module.exports = requestLogger;
Code Quality Tools
Use tools to enforce code quality and consistency.
ESLint Configuration
Create an .eslintrc.js
file in your project root:
module.exports = {
env: {
node: true,
es2021: true,
jest: true
},
extends: 'eslint:recommended',
parserOptions: {
ecmaVersion: 12,
sourceType: 'module'
},
rules: {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }]
}
};
Prettier Configuration
Create a .prettierrc.js
file:
module.exports = {
singleQuote: true,
trailingComma: 'es5',
printWidth: 100,
semi: true,
tabWidth: 2,
};
Husky and lint-staged
Setup pre-commit hooks to enforce code quality before commits:
- Install dependencies:
npm install --save-dev husky lint-staged
- Add to package.json:
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.js": [
"eslint --fix",
"prettier --write"
]
}
}
Summary
In this guide, we've explored key strategies for maintaining Express.js applications:
- Project Structure: Organize files logically by feature or function
- Modular Programming: Follow the Single Responsibility Principle and use dependency injection
- Error Handling: Implement a centralized error handling system
- Configuration Management: Separate config from code using environment variables
- Documentation: Document code and API endpoints
- Testing: Write testable code and comprehensive tests
- Logging: Implement a robust logging strategy
- Code Quality Tools: Use ESLint, Prettier, and pre-commit hooks
By applying these practices, you'll build Express applications that are easier to maintain, understand, and extend over time.
Additional Resources
Exercises
- Refactor an existing Express route to follow the controller-service pattern
- Implement centralized error handling in an Express application
- Create a custom logger middleware that logs request details
- Set up a test suite for Express route controllers using Jest
- Create a configuration module that uses environment variables
By practicing these exercises, you'll improve your ability to write maintainable Express applications.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)