Open-Closed Principle: The Hard Parts
Mohamed Mayallo
Posted on September 29, 2022
Introduction
SOLID principles are a set of principles set by Robert C.Martin (Uncle Bob). The main goal of these principles is to design software that is easy to maintain, test, understand, and extend.
These principles are:
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion
After introducing the Single Responsibility Principle in a previous article, in this article, we will discuss the Open-Closed Principle the second principle in SOLID.
Lets agree on something
Before diving into this principle, lets agree on something. I believe you agree with me that software design is not a straightforward process and the main characteristic of any software is its mutability nature over its lifetime.
If we knew that upfront, I think the main goal we need to achieve is building software in a way that accommodates this mutability and minimizes the chances to introduce new bugs due to changing business requirements.
In fact, the word SOFT_WARE promises that any software should be flexible and mutable. So, software should follow some proven design principles in order to achieve this promise, one of them is the Open-Closed Principle (OCP).
The theory
The Open-Closed Principle is responsible for the O in the SOLID principles. Robert C. Martin considered this principle the most important principle of object-oriented design. However, he wasn't the first one who defined it. Initially, Bertrand Meyer wrote about it in 1988 in his book Object-Oriented Software Construction. He stated it as:
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification
But what does that mean? Simply, it means that if your business requirements change you shouldn't modify the existing code ( Closed for Modifications ). Instead, you should add a new code that extends the existing code without affecting it (Open for Extension).
As a result, the final entity that is complied with this principle would have two features:
- Stable : As this entity is closed for modifications , its behavior doesn't change and it is well-defined. So that there are no side effects on the code that consumes this entity.
- Flexible : As this entity is open for extensions , you can add new functionalities without affecting the existing code.
If you're reading about this principle for the first time, you might think that there is a contradiction. How software should be closed for modifications and open for extensions at the same time?
Well, to clarify this point, lets introduce the Plugin Architecture.
Plugin Architecture as a practical example for OCP
In his article, Uncle Bob said:
Plugin systems are the ultimate consummation, the apotheosis, of the Open-Closed Principle
But what's making the Plugin Architecture so special? Think of it, if your system is built of some plugins which could be plugged or unplugged easily without affecting each other. Every plugin does one single responsibility and does it very well. The system knows nothing about any new plugin that needs to extend it. Instead, this new plugin just has to fulfill the system contract. That means adding or removing a plugin doesn't impact the existing code at all.
Yes as you might think, the OCP and SRP are somehow connected with each other.
Let me cite what Uncle Bob said to emphasize how powerful is the plugin architecture:
What if the design of your systems was based around plugins, like Vim, Emacs, Minecraft, or Eclipse? What if you could plug in the Database, or the GUI? What if you could plug in new features, and unplug old ones? What if the behavior of your system was largely controlled by the configuration of its plugins? What power would that give you? How easy would it be to add new features, new user interfaces, or new machine/machine interfaces? How easy would it be to add, or remove, SOA? How easy would it be to add or remove REST?
Well, that's interesting, isn't it?
Do you really get it very well?
I believe that you think like me when I studied this principle: Really! that's interesting. If we can achieve this principle in our design or building any system based on plugins, there are no issues or breaking changes we would face in the future.
But think again, what would you do if you need to add a new feature to your class? Typically, the first thing you usually do is open this class and add your feature, which is very reasonable in most cases.
Take a look at this example:
class Order {
amount = 3;
calculateCost() {
return this.amount * 0.9; // 10% tax
}
}
If you have the previous example, and your manager asks you to remove the tax. What is the first thing you're going to do? That's obvious.
So, does it make sense to you to add/remove new/old functionality without touching the existing code? The short answer is yes, but it isn't as simple as you think. Lets continue.
The hard parts of OCP
I think that the OCP is the most misunderstood among the 5 SOLID principles. However, if you apply it correctly in your design, it will be the most helpful than the others.
Possibly, you might read the definition of this principle and then ask yourself, how can I apply it correctly? what are the steps that I have to follow to fulfill it?
Unfortunately, there is no one way to write code that never needs to be modified and always be open to being extended. The one class that might be never modified at all is something like that:
Another point you have to keep in mind is dealing with bugs. What would you do if your class has a bug? would you forcibly extend it and leave a legacy code having a bug to blindly fulfill the OCP? or would you simply open your class and modify this bug directly? So I believe that fixing bugs should be an exception to the OCP.
We need to be able to predict where the variation in our code is and apply the abstraction around it. At the same time, we dont want to develop software with too much complexity upfront that is trying to guess every possible way that it might have to be modified in the future. So, prediction is the key, and at the same time, the hardest to fulfill this principle. A more concise way to follow the OCP is by introducing the Point of Variation (PV) principle that states:
Practical ways to follow the OCP
I have tried to simplify the previous section as I can, but in case you lost, here are the practical ways to follow the OCP:
- If you cant predict the points of variation in your software upfront, simply, start concrete and simple. Then, as the software evolves, start your refactoring and building the right abstraction around these points.
- If you made sure 100% in your prediction about the points of variation upfront, try to identify the right level of abstraction your software need without overcomplicating things.
After introducing the OCP in theory and its limitations, lets jump into practical examples to see how can we apply the OCP in our code.
Approaches to applying the OCP
Lets start with this concrete example, and try to refactor it with the OCP in mind.
class Logger {
// Concrete function. Does one thing in exactly one way
// If you want to change its functionality, you have to modify it directedly.
log() {
console.log('Hi');
}
}
const logger = new Logger();
logger.log();
1- Function parameters
This approach is the simplest and the most intuitive way to apply the OCP and at the same time, the ideal choice in Functional Programming.
Simply, in this approach, by passing arguments to a function, for example, we can change its functionality. This function would be open for extensions by changing its arguments.
Lets refactor the above example by applying the OCP and keep this approach in mind:
class Logger {
log(message: string) {
console.log(message);
}
}
const logger = new Logger();
logger.log('Hello');
Now, as you see, this function can print any message instead of just a fixed one. So this function is opened for extensions by changing the message it prints.
2- Partial Abstraction via Inheritance
As we said, the OCP was first used by Bertrand Meyer in his book Object-Oriented Software Construction. In fact, Meyers original approach was to use inheritance as a core mechanism to apply the OCP.
Whenever your class needs to be changed, instead of modifying the existing functionality, this approach encourages you to create a new subclass that holds the new implementation or overrides the original one as required. And leave the original implementation unchanged.
Lets refactor our example and keep this approach in mind:
class Logger {
log() {
console.log('Hi');
}
}
class AnotherLogger extends Logger {
log() {
console.log('Hi from Another');
}
}
// const logger = new Logger();
const logger = new AnotherLogger();
logger.log();
As you see, instead of modifying the original class Logger
, we have just added a new subclass AnotherLogger
that overrides the parent class behavior which is the log
method.
As a side note, you should avoid using inheritance if possible, because inheritance introduces tight coupling if the subclasses depend on the implementation details of their parent class. If the parent class changes, it would affect the subclasses and they may need to be modified as well.
3- Total Abstraction via Composition and Interfaces
Maybe, you heard before about Program to interfaces, not implementations or Composition over inheritance, didn't you? Because of inheritance limitations, Robert C. Martin redefines the OCP to use composition and interfaces instead of Meyers inheritance. But how can we use it?
In this approach, instead of setting your new functionality directly in the main class, you move it to another class and then reference this new class into the main class by Dependency Injection. And any injected class must implement an interface. Once the new class implements this interface correctly, the main class can eventually use its functionality. That's how can you use composition and interfaces over the inheritance.
Lets jump into our example and apply the composition using interfaces:
interface ILogger {
log(): void
}
class AnotherLogger implements ILogger {
log() {
console.log('Hi from Another');
}
}
class AnotherElseLogger implements ILogger {
log() {
console.log('Hi from Another Else');
}
}
class Logger implements ILogger {
logger: ILogger;
constructor(logger: AnotherLogger) {
this.logger = logger;
}
log() {
this.logger.log();
}
}
const anotherLogger = new AnotherLogger();
const logger = new Logger(anotherLogger);
// const anotherElseLogger = new AnotherElseLogger();
// const logger = new Logger(anotherElseLogger);
logger.log();
As you see, now the Logger
class is independent of any entity. Only the injected instance has to implement the ILogger
interface. So you can use AnotherLogger
or any logger you want as long as it implements the ILogger
interface.
The main benefit of this approach is achieving Polymorphism which in turn achieves the Loose Coupling.
Whereas, programming to interfaces introduces a new layer of abstraction. The interface itself is considered to be closed not the implementation because there might be many different implementations of one interface at the same time. The interface itself is reused not the implementation. Which in turn, leads to loose coupling.
Strategy Design Pattern
The strategy design pattern is a great example that achieves the OCP in an elegant way. It is one of the most useful design patterns. It is mainly based on programming to interfaces. Lets see how it works.
You have a problem that can be solved in multiple ways. These ways are called Strategies, every strategy encapsulates a different solution for the problem. All these strategies must implement one interface to solve this problem. This problem is in a class called Context. These strategies can be injected into the context in many ways like Dependency Injection , Factory Design Pattern , or simply, by If Condition. Take a look at this diagram:
Now, your code is open for extensions as it enables you to use different strategies as long as they implement the required interface. And closed for modifications because the context class itself doesn't have to be changed, it solves its problem with any strategy, no matter what exactly the strategy is.
I wrote an article before about the Strategy pattern, you can check it out here.
Benefits of OCP
After this explanation, I think you have an idea about the benefits of applying OCP in your code. Lets summarize some of them:
- If you have a package that is consumed by many users, you can give them the ability to extend the package without modifying it. In turn, that reduces the number of deployments which minimizes the breaking changes.
- The less you change the existing code, the less it would introduce new bugs.
- The code becomes simpler, less complex, and more understandable. Look at the Strategy pattern in the section above.
- Adding your new functionality to a new class enables you to design it perfectly from scratch without polluting or making workarounds in the existing code.
- Because the new class is dependent on nothing, you just need to test it not all the existing code.
- Touching the existing code in legacy codes can be extremely stressful, so adding new functionality in a new class mitigates that stress.
- Any new class you create is following the Single Responsibility Principle.
- It enables you to modularize your code perfectly, which in turn, saves time and cost.
Summary
After introducing the Single Responsibility Principle, the first SOLID principle, in an earlier article, in this article, we have discussed the Open Closed Principle, the second one.
As we saw, this principle is one of the most important design principles. Following it lets you create modular, maintainable, readable, easy-to-use, and testable applications.
Although the benefits you gain from applying this principle, it isn't easy to apply it. There is no one way to apply it, it is impossible to predict every single point of variations upfront, and it is hard to define the right level of abstraction your application really needs.
On top of that, we introduced a practical way to follow this principle. Firstly, solve your problem using simple concrete code. Don't abstract everything upfront. Secondly, try to identify the point of variations in your application. If you have a feature that continually changes, it might be a point of variation. Finally, if you have identified these points, you should likely think about OCP, modify your code and try to make it extensible as you can for future needs.
Finally, we have introduced three ways that enable you to apply the OCP and knew that the recommended way is using Composition and Interfaces.
If you found this article useful, check out these articles as well:
- Do you really know, what is Single Responsibility?
- What is the difference between Strategy, State, and Template design patterns?
Thanks a lot for staying with me up till this point, I hope you enjoy reading this article.
Resources
- The Open Closed Principle
- SOLID Principles for C# Developers
- SOLID Design Principles Explained: The Open/Closed Principle with Code Examples
- The Open-Closed Principle Explained
- SOLID Design: The Open-Close Principle (OCP)
- Should We Follow The Open-Closed Principle?
- Open-Closed Principle is nothing about the code
- Why the Open Closed Principle is the one you need to know but dont
- THE OPEN-CLOSED PRINCIPLE, IN REVIEW
Posted on September 29, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.