Understanding Open/Closed Principle (OCP) for Scalable Solutions

Last updated on May 17th, 2024 at 06:33 am

This blog is in continuation of my previous blog How to Correctly Apply the Single Responsibility Principle (SRP) for Better Code. Today we are going to learn much more in detail about the next principle in SOLID Principles for Scalable Solutions that is the Open/Closed Principle also known as OCP.

Introduction

Imagine you’re building an e-commerce application, and you need to frequently update the discount calculation logic. Each time you modify the existing code, new bugs creep in, and the system becomes unstable. Frustrating, right?

The Problem

Constantly altering existing code not only introduces bugs but also makes the system harder to maintain and scale. How can we manage evolving requirements without compromising the stability of our application? This can be mitigated by introducing Open/Closed Principle.

What is Open/Closed Principle (OCP)?

The Open/Closed Principle (OCP) is one of the five SOLID principles of object-oriented design and programming. It was formulated by Bertrand Meyer and later popularized by Robert C. Martin (Uncle Bob). The principle states:

Definition: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

This means that the behavior of a module can be extended without modifying its source code. The goal is to write code that does not need to be changed every time the requirements change. Instead, new functionality should be added by adding new code. This seems more obvious when we consider that every change to a working code may introduce new bugs and we should not change something which is working fine.

The OCP principle helps us in achieving the same by providing a way out where we do not need to change the code mostly. But, how do we achieve that? To keep the context the same as the previous principle, we will take the same example of the User service which we created in NodeJs.

“If you look at the history of software development, you will see that OCP is the basis of most of the libraries, files, or frameworks created. This is how frameworks keep their files protected from developers. Even architectures like Service-Oriented Architecture exhibit this principle at the component level.”

In our last blog post, after applying the Single Responsibility Principle, this was the final result for out User service. You can also find it on GitHub at https://github.com/vinay-kumar/SOLID/tree/Single-Responsibility-Principle.

Fig 1 – Dependency Diagram

If we look at the above code, we can clearly see that the code seems okay as every class depends upon the class above. But, things don’t seem fine when we start asking ourselves the following questions:

  1. What will happen if we decide to add new database or even when we decide to change database schema?
  2. How will we ensure that the new data access class is used in Service instead of existing one without changing the Service file as it is consuming userDataAccess module directly?

Implementing OCP:

Before implementing the OCP, there’s one question we need to address: On which classes should we apply the OCP? Should we apply the OCP rule for every class available in our framework?

  1. Core Business Logic: We should apply OCP to classes that encapsulate core business logic. This ensures that new features can be added without modifying existing code.
  2. Public APIs and Services: Classes that expose public APIs or services should be designed with OCP in mind to maintain backward compatibility while allowing for extensions.
  3. Frameworks and Libraries: Framework and library classes should follow OCP to protect internal implementation and provide stable extension points for developers.

Let us try to apply Open/Closed Principle for each of the classes or functions in our User program.

database.js:

First one is database config file. This file creates and exports a Pool instance from pg. This file itself does not seem to violate the OCP.

Data Access Objects (userDataAccess.js):

Direct SQL Queries: The UserDataAccess class directly executes SQL queries. If the database schema changes, the queries would need to be modified, violating the OCP. A better approach might be to abstract these queries behind methods or use an ORM that can handle schema changes more gracefully.

Lets update the data access layer accordingly.

// userDataAccessInterface.js
class IUserDataAccess {
    createUser(userData) {}
    getUserById(userId) {}
    updateUser(userId, userData) {}
    deleteUser(userId) {}
}
module.exports = IUserDataAccess;
// userDataAccess.js
const IUserDataAccess = require('./userDataAccessInterface');
class UserDataAccess extends IUserDataAccess {
    async createUser(userData) {
        const query = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *;';
        const { rows } = await pool.query(query, [userData.name, userData.email]);
        return rows[0];
    }
    async getUserById(userId) {Note, now
        const query = 'SELECT * FROM users WHERE id = $1;';
        const { rows } = await pool.query(query, [userId]);
        return rows[0];
    }
    async updateUser(userId, userData) {
        const query = 'UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *;';
        const { rows } = await pool.query(query, [userData.name, userData.email, userId]);
        return rows[0];
    }
    async deleteUser(userId) {
        const query = 'DELETE FROM users WHERE id = $1;';
        await pool.query(query, [userId]);
    }
}
module.exports = UserDataAccess;

userService.js:

The UserService class directly calls methods on UserDataAccess. This is not inherently a violation of the OCP, but if the underlying data access logic changes, UserService might need to be modified. Introducing an interface for UserDataAccess and implementing that interface could improve this, allowing different implementations to be swapped without changing the UserService.

// userService.js
class UserService {
    constructor(userDao) {
        this.userDao = userDao;
    }
    async addUser(userData) {
        return await this.userDao.createUser(userData);
    }
    async findUser(userId) {
        return await this.userDao.getUserById(userId);
    }
    async updateUser(userId, userData) {
        return await this.userDao.updateUser(userId, userData);
    }
    async deleteUser(userId) {
        return await this.userDao.deleteUser(userId);
    }
}
module.exports = UserService;

Note: This is more of dependency injection and also Liskov Substitution Principles but this helps greatly in making our service resistant to change.

userController.js:

Direct Interaction with UserService: Similar to userService.js, this file directly interacts with UserService. If UserService changes, the controller might need modifications. Using dependency injection and interfaces could help to adhere to the OCP.

// userController.js
class UserController {
    constructor(userService) {
        this.userService = userService;
    }
    async createUser(req, res) {
        try {
            const user = await this.userService.addUser(req.body);
            res.status(201).json(user);
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }
    async getUser(req, res) {
        try {
            const user = await this.userService.findUser(req.params.id);
            res.json(user);
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }
    async updateUser(req, res) {
        try {
            const user = await this.userService.updateUser(req.params.id, req.body);
            res.json(user);
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }
    async deleteUser(req, res) {
        try {
            await this.userService.deleteUser(req.params.id);
            res.send('User deleted successfully');
        } catch (err) {
            console.error(err);
            res.status(500).send('Server error');
        }
    }
}
module.exports = UserController;

app.js:

app.js is the main program file. This is the entry point of the program and it is OK to be open for changes. Let’s update the app.js file to accommodate the above changes.

// app.js
const express = require('express');
const UserDataAccess = require('./userDataAccess');
const UserService = require('./userService');
const UserController = require('./userController');
const app = express();
app.use(express.json());
const userDao = new UserDataAccess();
const userService = new UserService(userDao);
const userController = new UserController(userService);
app.post('/users', (req, res) => userController.createUser(req, res));
app.get('/users/:id', (req, res) => userController.getUser(req, res));
app.put('/users/:id', (req, res) => userController.updateUser(req, res));
app.delete('/users/:id', (req, res) => userController.deleteUser(req, res));
const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
// app.js
const express = require('express');
const UserDataAccess = require('./userDataAccess');
const UserService = require('./userService');
const UserController = require('./userController');
const app = express();
app.use(express.json());
const userDao = new UserDataAccess();
const userService = new UserService(userDao);
const userController = new UserController(userService);
app.post('/users', (req, res) => userController.createUser(req, res));
app.get('/users/:id', (req, res) => userController.getUser(req, res));
app.put('/users/:id', (req, res) => userController.updateUser(req, res));
app.delete('/users/:id', (req, res) => userController.deleteUser(req, res));
const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

Lastly, following is the updated dependency diagram for our program.

Finally, our UserService is not dependent on the concrete database access layer. This makes the application more open for extension and closed for modification. We can keep on adding abstraction in our application to make it more resilient to changes, but do keep in mind that your business logic should be as closed as possible; other things are just details that your application should not worry about.

Conclusion

Applying the Open/Closed Principle (OCP) correctly can significantly enhance the maintainability and scalability of your software. By ensuring that your code is open for extension but closed for modification, you can introduce new functionalities with minimal changes to existing code. This principle is a cornerstone of the SOLID principles and helps in building robust and flexible software systems. Implementing OCP requires thoughtful design and a focus on abstraction, which ultimately leads to cleaner, more manageable code.

Do share your comments.

References:

Here are some valuable resources that provide in-depth knowledge about the Open/Closed Principle and other SOLID principles:

  1. “Clean Architecture: A Craftsman’s Guide to Software Structure and Design” by Robert C. Martin – This book gives detailed insights into SOLID principles and their practical applications.
    Buy on Amazon
  2. Refactoring.Guru – This website is excellent for learning about design patterns and principles, including SRP, with clear explanations and examples.
    Visit Refactoring.Guru on SRP
  3. “Clean Code: A Handbook of Agile Software Craftsmanship” by Robert C. Martin – This book is crucial for developers looking to improve their coding practices and embrace SRP.
    Buy on Amazon
  4. Martin Fowler’s Blog – A resource rich with articles on software architecture, including discussions on the responsibilities of software components.
    Explore Martin Fowler’s Blog

Discover more from kumarvinay.com

Subscribe to get the latest posts sent to your email.

Leave a Reply

Discover more from kumarvinay.com

Subscribe now to keep reading and get access to the full archive.

Continue reading