Open-Closed Principle: The Hard Parts

Open-Closed Principle: The Hard Parts

The Open-Closed Principle is the second principle of SOLID. Let's know what OCP is, its limitations, and how can we follow it correctly

Mohamed Mayallo's photo
Mohamed Mayallo
·Sep 29, 2022·

14 min read

Featured on Hashnode

Subscribe to my newsletter and never miss my upcoming articles

Play this article

Table of contents

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:

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.

Let’s agree on something

Before diving into this principle, let’s 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).

tyler-nix-KLLcTHE20bI-unsplash.jpg

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, let’s introduce the Plugin Architecture.

Plugin Architecture as a practical example for OCP

sean-whelan-NG_a-z0ScM0-unsplash.jpg

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. Let’s 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?

  1. 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:

     class TotallyAbstracted {
         loggerFunction;
         constructor(loggerFunction) {
             this.loggerFunction = loggerFunction;
         }
         log(message) {
             this.loggerFunction(message);
         }
     }
    

    You may notice that this class is extreme in abstraction. This class has no functionality on its own and 100% of its functionality is passed into it, so it might never need to be modified.

    On the other hand, take a look at this example below:

     class TotallyConcrete {
         sayHi() {
             console.log('Hi!');
         }
     }
    

    Obviously, this class is extreme in concreteness. This class does one thing in exactly one way and if you want to change its functionality, you have to modify it directedly.

    Actually, any useful code must lie between the two extremes. It has to be partially concrete to achieve its functionality and, if needed, partially abstracted to be reused or maintained easily.

    As a result, never get the extremist idea that your code mustn’t be modified only extended. And keep in mind, that either the extreme in abstraction or the extreme in concreteness both have their cost. So, the hard point here is defining the right level of abstraction and defining the balanced point between the extremes.

    One possible way to identify this point is to start by being concrete, except you made sure you need abstraction 100%, and see how the application evolves over time. When changes are needed, just make them in the existing code concretely for the first time or two. However, by the third time, it might be an indicator that the software is likely to continue to change in this manner in the future. So you should think about refactoring your code to follow the OCP and providing the needed abstraction.

    In my opinion, the OCP doesn't tell you to abstract everything to get the ultimate universal design, because, that’s impossible. Instead, if you have strong reasons for abstraction, simply, do it, and if you haven’t, simply, don’t do it. The concrete design is fine until it isn’t. Remember the KISS principle.

    So, inevitably, you might need to modify your existing code or even more, your abstraction.

  2. 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.

  3. 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 don’t 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:

    Identify points of predicted variation and create a stable interface around them.

    But be careful when predicting your variations. The costs of incorrectly predicting variations can be high:

    • If you predict variation that won’t vary, you eventually would waste your effort on over-engineering.
    • If you don’t predict variation that will be actually needed, you eventually would need to modify a lot of existing code.

    So the bottom line regarding this point:

    • Don’t abstract things just because you predict that you need them. Keep in mind that developing and maintaining abstraction has its cost. But if upfront, you made sure 100% that you will need it, go ahead.
    • You aren’t supposed to predict every single variation in your software, however, it is your responsibility to define the point of variations in your software.
    • Once the need for variation becomes real, that’s the time to refactor your code and provide the right abstraction if you really need it.

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:

  1. If you can’t 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.
  2. 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, let’s jump into practical examples to see how can we apply the OCP in our code.

Approaches to applying the OCP

jaye-haych-7tkDoo2L_Eg-unsplash.jpg

Let’s 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.

Let’s 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, Meyer’s 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.

Let’s 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 Meyer’s 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.

Let’s 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

jani-kaasinen-7VGzV09YnvA-unsplash.jpg

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. Let’s 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:

strategy.png

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. Let’s summarize some of them:

  1. 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.
  2. The less you change the existing code, the less it would introduce new bugs.
  3. The code becomes simpler, less complex, and more understandable. Look at the Strategy pattern in the section above.
  4. 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.
  5. Because the new class is dependent on nothing, you just need to test it not all the existing code.
  6. Touching the existing code in legacy codes can be extremely stressful, so adding new functionality in a new class mitigates that stress.
  7. Any new class you create is following the Single Responsibility Principle.
  8. 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:

Thanks a lot for staying with me up till this point, I hope you enjoy reading this article.

kevin-xue-SNMKcGFB-0w-unsplash.jpg

Resources

 
Share this