This blog is part of a series explaining the SOLID design principles by looking at code from real projects or frameworks. Last time, we looked at the Single Responsibility Principle. This time, we’ll discuss the Open/Closed Principle.
SOLID is an acronym for the following design principles:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
The SOLID principles are often explained by using simple examples, but that sometimes makes it hard to spot them in your own code for a real project. That’s why I searched for some examples of these issues in Open Source projects and use them in this series.
The Open/Closed Principle
The Open/Closed Principle, created by Bertrand Meyer in 1988, states:
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
If you want to add functionality to a program and you need to change existing, working code to make it work, you always run into the risk of breaking things that used to work (regression) or forcing you to change other parts of the code as well to be able to deal with the change. If followed correctly, the Open/Closed Principle allows us to add functionality without touching existing code. Thereby preventing a change to cascade into existing parts of the program and possibly creating regression.
What does Open for extension and Closed for modification mean? Open for extension means that the behavior of a software entity can be extended. This allows for making changes required to satisfy new requirements. Closed for modification means that we must not modify the code to extend it with new behavior. In other words: adding new behavior should not lead to changes in the source code.
If you read these two rules, at first it seams that they are conflicting. How can we change or add behavior without touching the source code? The solution lies in using abstraction. Instead of directly adding behavior to a software entity, we create an abstraction for the behavior and use this. This way, we can create new abstractions for functionality and leave the code that is calling the abstraction as is.
An example violation in the wild
Again, let’s look at some code from the NLog framework, a .NET framework that deals with logging and is similar to frameworks like Log4j in Java. I selected this project not because it has low quality code but because it is a medium sized project and has multiple contributors. Chances are that you’ll find potential issues like this in any reasonably sized project, either introduced consciously or unconsciously. That is one of the things that make Open Source great: we can be exposed to others’ work learn from it.
In this project, there is a class named InternalLogger. This is a static class (thus only with static methods, properties and fields) which probably is done for performance reasons. This could also be the reason this design is used. Nevertheless, we can still use it as an example violation. The class has a method Write (shortened to make violation clear):
Let’s say that we want to add another destination to write our message to. Can we do this without touching this code? Unfortunately, not. If we continue this way of working, we would add a private Method WriteTo… that writes to our destination, and call it from the public Write() method.
To satisfy the Open/Closed Principle, what we can do is create an interface IWriter that has a method Write(string message) and create implementations for LogFile, TextWriter, Console and others. In InternalLogger, we can take a list of IWriters and call each one of them. This way, we also get rid of the environment conditionals that cloud up the real intention of the method. In the Reset() method (or somewhere else), we can take care of creating the list with writers. If conditionals are needed for dealing with the environments, at least it is in one place.
Like I said, there could be a good reason why the developers of NLog solved it this way.
How to spot these kinds of violations
You are violating this principle if you directly work with a concrete implementation instead of an abstraction. The violation becomes visible when you must extend existing code to accommodate new functionality.
Some signs that your code potentially has these kinds of violations:
- You have private methods that almost do the same thing, but with a slight variation in the implementation (in the NLog example, the WriteToXXX methods)
- You use (a lot of) ifs to control behavior, e.g. doing something the old way or the new way.
- You use an abstract class but check for the concrete implementation to control flow (e.g. in C#: Shape is Rectangle)
How to solve these violations
There are different ways to adhere to this principle. One way is to create an interface (or abstract class) for the behavior, implement the interface for a certain behavior and use the interface from the calling class. This way, you can add or change behavior without having to touch the logic that uses the behavior. You can use the Strategy Pattern for this.
Figure 1 Strategy Pattern
Another way of dealing with this problem is by using the Template Method pattern. In this pattern, you create an abstract class containing the base logic, and create implementations of it for different behavior. If you want to add new behavior, you simply create a new class, derive from the base class and add the functionality. The base class remains the same.
Figure 2 Template Method Pattern
If you want to extend functionality (do the original thing and something extra before and/or after) but not want to change the existing class, you could apply the Decorator Pattern to achieve this.
Figure 3 Decorator Pattern
The Open/Closed Principle allows you to add functionality to existing functionality without changing the existing source code. This makes changes more localized and reduces the chance of adding regression to your product.
This blog was written by Harm Pauw.