Design patterns are proven solutions to common architectural problems in software development. Applying the right design patterns can make your Express.js application scalable, maintainable, and robust. In this chapter, we will explore a range of design patterns, from basic to advanced, tailored for building scalable Express applications.
Design patterns are reusable solutions to common problems encountered in software design. They provide templates for writing code in a structured and efficient manner.
Scalability ensures that an application can handle a growing number of requests, while maintainability ensures the ease of updating or fixing the code.
The middleware pattern is fundamental to Express.js. Middleware functions process requests in a sequence.
const express = require('express');
const app = express();
// Middleware for logging
function logger(req, res, next) {
console.log(`Request Method: ${req.method}, URL: ${req.url}`);
next();
}
app.use(logger);
app.get('/', (req, res) => {
res.send('Hello, World!');
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Middleware functions like logger
are used to perform actions like logging, authentication, or error handling before passing control to the next handler.
The router pattern organizes application routes into separate modules for better scalability.
// userRoutes.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.send('List of users');
});
router.post('/', (req, res) => {
res.send('Create a user');
});
module.exports = router;
// main app
const userRoutes = require('./userRoutes');
const app = express();
app.use('/users', userRoutes);
app.listen(3000, () => console.log('Server running on port 3000'));
The router pattern modularizes routes, making it easier to scale and manage as the application grows.
Dependency injection decouples modules by injecting dependencies instead of hardcoding them.
class Logger {
log(message) {
console.log(message);
}
}
class UserService {
constructor(logger) {
this.logger = logger;
}
getUser() {
this.logger.log('Fetching user');
return { id: 1, name: 'John Doe' };
}
}
const logger = new Logger();
const userService = new UserService(logger);
console.log(userService.getUser());
Injecting Logger
into UserService
makes the UserService
more testable and easier to replace dependencies.
The singleton pattern ensures that a class has only one instance.
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = 'Connected to DB';
Database.instance = this;
}
}
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true
Using a singleton ensures that only one database connection is created, saving resources.
The repository pattern abstracts data access, making it easier to switch databases.
class UserRepository {
constructor(db) {
this.db = db;
}
async getUserById(id) {
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
const db = {
query: (sql) => Promise.resolve({ id: 1, name: 'John Doe' }), // Mock DB
};
const userRepository = new UserRepository(db);
userRepository.getUserById(1).then(console.log);
The factory pattern creates objects without specifying the exact class.
class UserService {
static createUser(type) {
if (type === 'admin') {
return { id: 1, role: 'admin' };
} else {
return { id: 2, role: 'user' };
}
}
}
console.log(UserService.createUser('admin'));
The observer pattern allows modules to subscribe to events and react asynchronously.
const EventEmitter = require('events');
const eventEmitter = new EventEmitter();
eventEmitter.on('userCreated', (user) => {
console.log(`User created: ${user.name}`);
});
eventEmitter.emit('userCreated', { id: 1, name: 'John Doe' });
Handles business logic.
Handles HTTP requests and responses.
Handles database queries.
// repository.js
class UserRepository {
getUserById(id) {
return { id, name: 'John Doe' }; // Mock DB
}
}
// service.js
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
getUser(id) {
return this.userRepository.getUserById(id);
}
}
// controller.js
const express = require('express');
const app = express();
const UserRepository = new UserRepository();
const UserService = new UserService(UserRepository);
app.get('/user/:id', (req, res) => {
const user = UserService.getUser(req.params.id);
res.send(user);
});
app.listen(3000, () => console.log('Server running on port 3000'));
Applying the patterns discussed, a REST API is built with:
Design patterns provide a structured approach to building scalable and maintainable Express.js applications. By understanding and applying these patterns, developers can create robust systems ready to handle growth and complexity. Happy coding !❤️