Open-Closed Principle (OCP), which is the “O” in SOLID design principles is perhaps less discussed. Though I’ve known it for awhile, I’ve never spent the time to really appreciate the value it adds. Usually, all I hear is that any time you use a switch-case statement, you could have used polymorphism instead (and that would obey OCP). And sure enough, moving behavior into separate, polymorphic types has always felt cleaner to me. In this article, I want to explore why exactly following the Open-Closed Principle is a good idea.
The Principle
The Open-Closed Principle, credited to Bertrand Meyer in his Object Oriented Software Construction, states:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
What does this mean? Well, the idea is that you should be able to extend the capabilities of a module without needing to modify its source code. When applied correctly, there are 2 or more modules at play here. One module is stable and has a well-defined contract, while other modules extend the first via inheritance, interface implementation, higher-order functions, or some other mechanism.
If you’ve been in software for any length of time, perhaps this idea isn’t too novel. If you’ve needed to subclass a framework type, or provide your own concrete class for a framework interface, then you’ve seen Open-Closed Principle. Generally, framework authors provide abstractions to allow developers to provide their own custom logic to account for less-common or unforeseen scenarios.
Consider UI frameworks like WPF or Xamarin. WPF provides a FrameworkElement
class (and in Xamarin it is Layout<T>
). These classes are used by the framework to create many different layout containers, but the framework authors can’t predict every scenario. So, they defined a stable contract and implemented a few common scenarios like grids, but they left the contract open for extension to allow developers to provide their own layout containers that plug-in to the visual tree like any other view object.
Note that this only works well when the framework is truly stable! If API contracts change too frequently or too dramatically with backwards-incompatible changes, then developers will need to continually update their extensions to continue integrating. So, the goal should be to design API contracts that will not require future modification, they should be closed.
Benefits
To appreciate the benefits, let’s consider a few qualities that make good software:
- Reusability/Flexibility – a module can be reused to handle a wide variety of inputs
- Clarity – a module should be easy to read and understand
- Correctness – a module should be free of defects
- Stability- modules should not be so fragile so as to easily introduce defects
- Testability – a module should be easy to put under test, especially automated unit tests
Reuse
Open-Closed Principle helps with reuse. When modules are closed for modification, they can more reliably be depended on; it is difficult to reuse modules that constantly change. The simplest form of closing a module is by using version control, but sharing binaries would also suffice. In either case, a version of the module is frozen and made available to consumers. On the other hand, by opening a module for extension, we are forced to think about how its design can be made future-proof, so Open-Closed Principle certainly promotes reuse!
Clarity
Open-Closed Principle creates clear boundaries. In the process of defining extension points, an abstraction has to be defined so developers know how to extend a module. And once extended, there are two interfacing modules (a consumer and a producer). The producer is responsible for defining the abstraction for the internal logic that can be extended, while the consumer is responsible for plugging into the extension point.
What this means is that the producer is hiding details from the consumer, and so a separation of concerns is established. The producer and consumer are at different layers in the architecture, having different levels of abstraction.
If the producing module exposes a higher-order function as an extension point, then the logic for deciding when and how to invoke a function is hidden (e.g. like list traversal logic). And if the producing module allows extension through inheritance or abstract classes, then any class behavior not overridden by the consumer is hidden. Last, if exposed by explicit interfaces, then anything using the interface is hidden.
Whatever the case is, details of the producer are kept separate and hidden from the consumer, allowing for sharper clarity being the roles and responsibilities of each. I would also say that this separation of concerns approaches a single level of abstraction in each module. I talk a bit about how this helps with clarity in my article on Dependency Inversion Principle.
Correctness
Open-Closed Principle helps with correctness on two fronts: by promoting stability and testability.
In the case of stability, we already know that the Open-Closed Principle dictates that modules should be closed for modification. And this means they need to be designed for extension to accommodate a wide-variety of scenarios (or else they won’t be very useful). Since all of this forethought goes into creating extension points, we can generally expect producer modules not to change as frequently from changing stakeholder requirements (rather, the consumer modules would). And so, there is more time for the “dust to settle” to detect and correct any defects.
In the case of testability, I think this really ties back to the separation of concerns between the producer and consumer. Because extension points are defined, it is easier to write unit tests for the individual modules without having to disentangle high-level and low-level concerns. Modules are tested separately, and each module has a clearer set of responsibilities. The edge cases for each set of tests are more easily understood because their scope is now more narrow. With improved testability, we can more easily create a regression test suite to keep the software defect-free.
Switch Statements
Before wrapping up, I wanted to touch a bit more on the switch-case example I brought up originally. Switch statements (or if/else-if chains) are an obvious violation to Open-Closed Principle.
Why? Because each time you need to introduce a new case or modify an existing one, you have to go back into the containing module, rebuild it, and redeploy it. It’s not very reusable because there is no clear extension point for consumers. Instead, it would be better to define a class or interface and move behavior from each case into a function of a polymorphic type (the concrete class). Consider the following switch-case for connecting to various kinds of devices:
public class Client
{
public void Connect(DeviceType dev)
{
Connection? connection = null;
switch (dev) {
case Server:
// server connection logic
break;
case Database:
// database connection logic
break;
case SmartLight:
// smart light connection logic
break;
}
if (connection is not null) {
activeConnections.Add(connection);
} else {
Console.WriteLine("Failed to make connection.");
}
}
}
As you can imagine, updating this logic to accommodate new devices or new connection methods will cause this module to grow. We are also mixing the low-level connection details with the high-level concern of choosing which connection logic to execute. And you may have also noticed a violation of the Single-Responsibility Principle, this logic changes at different times for different reasons.
If we rethink our design to use polymorphism, we can greatly simplify our logic and derive the benefits of following the Open-Closed Principle. Here is the new and improved design:
In this design, all of the case logic gets split into different concrete classes, the client is no longer concerned with the device-specific connection logic, and we don’t need to pre-allocate and check for a null connection.
public class Client
{
private void Connect(Connectable dev)
{
try {
var connection = dev.Connect();
activeConnections.Add(connection);
} catch {
Console.WriteLine("Failed to make connection.");
}
}
}
There’s also the added benefit that we follow Single-Responsibility Principle too now. Whenever the connection requirements for one device change, we don’t need to update the module responsible for connection logic for every other device.
Final Thoughts
So, Open-Closed Principle is indeed good to follow! Personally, I think that the benefit of reuse is perhaps the most important. When we follow Open-Closed Principle, the modules we deploy to others will be easy to integrate with because they are stable and have clear extension points.
As shown earlier, following one of the SOLID design principles rarely happens in isolation. As you change your software to follow Open-Closed Principle, you may find yourself also following Single-Responsibility Principle or Dependency Inversion Principle. So, I highly recommend reading about the rest of SOLID, not only to create functional and elegant code, but to also become a more principled developer!
Leave a Reply