Express Service Layer
Introduction
When building Express applications, you might notice that your route handlers start to grow in complexity as your application evolves. They begin handling multiple responsibilities: parsing requests, validating data, executing business logic, handling errors, and formatting responses. This violates the Single Responsibility Principle and makes your code harder to maintain and test.
The Service Layer pattern helps solve this problem by extracting business logic from route handlers into dedicated service modules. This separation of concerns results in cleaner, more maintainable, and more testable code.
What is a Service Layer?
A service layer is an architectural pattern that acts as an abstraction between your route handlers (controllers) and your data access layer. It encapsulates the application's business logic, allowing route handlers to focus on HTTP-specific concerns like request parsing and response formatting.
Key benefits of implementing a service layer include:
- Separation of concerns: Route handlers focus on HTTP concerns, while services focus on business logic
- Code reusability: Business logic can be shared across different routes and applications
- Improved testability: Services can be tested in isolation without HTTP overhead
- Easier maintenance: Changes to business logic don't require changes to route handlers
Basic Service Layer Implementation
Let's start with a simple example. Imagine we have a basic Express app that manages users. Without a service layer, our route handlers might look something like this:
// userRoutes.js without service layer
const express = require('express');
const router = express.Router();
const User = require('../models/User');
// Get all users
router.get('/', async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (error) {
next(error);
}
});
// Get user by ID
router.get('/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
next(error);
}
});
// Create new user
router.post('/', async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if email already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'Email already in use' });
}
const user = new User({ name, email, password });
await user.save();
res.status(201).json(user);
} catch (error) {
next(error);
}
});
module.exports = router;
Now, let's refactor this code to use a service layer:
First, we'll create a user service that encapsulates all user-related business logic:
// services/userService.js
const User = require('../models/User');
class UserService {
async getAllUsers() {
return await User.find();
}
async getUserById(id) {
const user = await User.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
async createUser(userData) {
const { name, email, password } = userData;
// Check if email already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new Error('Email already in use');
}
const user = new User({ name, email, password });
await user.save();
return user;
}
}
module.exports = new UserService();
Then, we'll update our routes to use this service:
// userRoutes.js with service layer
const express = require('express');
const router = express.Router();
const userService = require('../services/userService');
// Get all users
router.get('/', async (req, res, next) => {
try {
const users = await userService.getAllUsers();
res.json(users);
} catch (error) {
next(error);
}
});
// Get user by ID
router.get('/:id', async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
res.json(user);
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({ message: error.message });
}
next(error);
}
});
// Create new user
router.post('/', async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
if (error.message === 'Email already in use') {
return res.status(400).json({ message: error.message });
}
next(error);
}
});
module.exports = router;
Advanced Service Layer Implementation
For a more advanced implementation, let's add error handling classes and dependency injection. This approach makes your code even more maintainable and testable.
First, let's create a custom error class:
// 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;
Now, let's update our service with better error handling:
// services/userService.js
const User = require('../models/User');
const AppError = require('../utils/AppError');
class UserService {
constructor(userModel) {
this.userModel = userModel;
}
async getAllUsers() {
return await this.userModel.find();
}
async getUserById(id) {
const user = await this.userModel.findById(id);
if (!user) {
throw new AppError('User not found', 404);
}
return user;
}
async createUser(userData) {
const { name, email, password } = userData;
// Input validation
if (!name || !email || !password) {
throw new AppError('Please provide name, email and password', 400);
}
// Check if email already exists
const existingUser = await this.userModel.findOne({ email });
if (existingUser) {
throw new AppError('Email already in use', 400);
}
const user = await this.userModel.create({ name, email, password });
return user;
}
}
// Create instance with dependency injection
module.exports = new UserService(User);
Let's create a controller that uses our service:
// controllers/userController.js
const userService = require('../services/userService');
exports.getAllUsers = async (req, res, next) => {
try {
const users = await userService.getAllUsers();
res.status(200).json({
status: 'success',
results: users.length,
data: { users }
});
} catch (err) {
next(err);
}
};
exports.getUserById = async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
res.status(200).json({
status: 'success',
data: { user }
});
} catch (err) {
next(err);
}
};
exports.createUser = async (req, res, next) => {
try {
const newUser = await userService.createUser(req.body);
res.status(201).json({
status: 'success',
data: { user: newUser }
});
} catch (err) {
next(err);
}
};
And update our routes:
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
router.route('/')
.get(userController.getAllUsers)
.post(userController.createUser);
router.route('/:id')
.get(userController.getUserById);
module.exports = router;
Finally, we'll add a global error handler:
// middleware/errorHandler.js
module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
};
And use it in our main app:
// app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
// Error handling middleware should be last
app.use(errorHandler);
module.exports = app;
Real-World Application: Authentication Service
Let's implement a more complex real-world example: an authentication service. This will demonstrate how a service layer can handle complex business logic while keeping your route handlers clean.
// services/authService.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const User = require('../models/User');
const AppError = require('../utils/AppError');
class AuthService {
constructor(userModel) {
this.userModel = userModel;
this.jwtSecret = process.env.JWT_SECRET || 'your_jwt_secret';
}
async register(userData) {
const { name, email, password } = userData;
// Check if user already exists
const existingUser = await this.userModel.findOne({ email });
if (existingUser) {
throw new AppError('User with this email already exists', 400);
}
// Hash password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create user
const user = await this.userModel.create({
name,
email,
password: hashedPassword
});
// Generate JWT
const token = this.generateToken(user._id);
// Return user without password and token
const userObject = user.toObject();
delete userObject.password;
return { user: userObject, token };
}
async login(email, password) {
// Check if user exists
const user = await this.userModel.findOne({ email }).select('+password');
if (!user) {
throw new AppError('Invalid email or password', 401);
}
// Validate password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
throw new AppError('Invalid email or password', 401);
}
// Generate JWT
const token = this.generateToken(user._id);
// Return user without password and token
const userObject = user.toObject();
delete userObject.password;
return { user: userObject, token };
}
generateToken(userId) {
return jwt.sign({ id: userId }, this.jwtSecret, {
expiresIn: '30d'
});
}
async validateToken(token) {
try {
if (!token) {
throw new AppError('No token provided', 401);
}
// Verify token
const decoded = jwt.verify(token, this.jwtSecret);
// Check if user still exists
const user = await this.userModel.findById(decoded.id);
if (!user) {
throw new AppError('User no longer exists', 401);
}
return user;
} catch (error) {
if (error.name === 'JsonWebTokenError') {
throw new AppError('Invalid token', 401);
}
throw error;
}
}
}
module.exports = new AuthService(User);
Now let's create controller functions for our authentication routes:
// controllers/authController.js
const authService = require('../services/authService');
exports.register = async (req, res, next) => {
try {
const { user, token } = await authService.register(req.body);
res.status(201).json({
status: 'success',
data: { user, token }
});
} catch (err) {
next(err);
}
};
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return next(new AppError('Please provide email and password', 400));
}
const { user, token } = await authService.login(email, password);
res.status(200).json({
status: 'success',
data: { user, token }
});
} catch (err) {
next(err);
}
};
exports.protect = async (req, res, next) => {
try {
// Get token from header
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
// Validate token and get user
const user = await authService.validateToken(token);
// Add user to request object
req.user = user;
next();
} catch (err) {
next(err);
}
};
Finally, let's set up the auth routes:
// routes/authRoutes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
router.post('/register', authController.register);
router.post('/login', authController.login);
module.exports = router;
Best Practices for Service Layer Implementation
-
Keep services focused: Each service should focus on a specific domain or entity (e.g., UserService, AuthService)
-
Use dependency injection: Pass dependencies to service constructors instead of importing them directly. This makes testing easier and loosens coupling.
-
Handle errors consistently: Create custom error classes for different types of errors and handle them in a central error handler.
-
Don't expose database models directly: Return plain objects or DTOs (Data Transfer Objects) from your services instead of database models.
-
Validate input at the service level: Don't assume that the input has been validated by the controller or middleware.
-
Follow the Single Responsibility Principle: Each method in your service should do one thing only.
-
Document your services: Use JSDoc or other documentation tools to describe what each service and method does.
-
Write unit tests: Services are much easier to test than controllers because they don't deal with HTTP requests and responses.
Summary
The Service Layer pattern is a powerful way to structure your Express applications, offering several benefits:
- It separates business logic from HTTP concerns, making your code more maintainable
- It promotes code reuse across different parts of your application
- It makes your code more testable by isolating business logic
- It clarifies the responsibilities of different parts of your application
By implementing a service layer, you'll create Express applications that are easier to maintain, test, and extend.
Additional Resources
Exercises
- Refactor an existing Express route to use a service layer
- Create a ProductService with methods to create, read, update, and delete products
- Implement a service layer for handling user permissions and role-based access control
- Add unit tests for a service using Jest or Mocha
- Create a service that interacts with an external API and caches results
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)