- Software Architecture: Meta and SOLID Principles in C#
- Introduction to SOLID Principles
- SOLID Principles: Single Responsibility Principle (SRP)
- SOLID Principles: The Open/Closed Principle (OCP)
- SOLID Principles: Liskov Substitution Principle (LSP) In Practice
- SOLID Principles: Interface Segregation Principle (ISP)
The second principle we’re going to talk about is the Open/Closed Principle. If we look at Wikipedia, we will see the following definition:
The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Problem Statement
What it means, in essence, is that when we need to introduce a change, we shouldn’t dig deep into the system and change its behavior by changing dozens of classes. We should be able to introduce a change by adding new code, not by changing the existing. Such software which allows changes via introducing new classes rather than via changing the existing source code is open for extension and closed for modification.
How to change behavior without changing the code base? The answer is simple – dynamic dispatch or polymorphism. We can achieve a very supple design by harnessing the power of Object-Oriented programming. In practice, it means that we need to rely on abstractions, rather than on concrete or direct implementations. In C#, we will rely on interfaces and abstract classes.
Why OCP?
Ok, but what’s the problem with changing the existing code? Why do we need to adhere to the Open/Closed Principle at all?
- The first reason is that there is a high chance of introducing bugs during the modification process. It’s much easier to introduce a bug modifying something that is complex and already exists than to introduce a bug adding new code.
- The second reason is relevant, and it is almost always relevant when you’re trying to modify the behavior of an API which is already in use by many clients.
The first danger here is to change the behavior expected by clients. Imagine that there are one thousand clients of a method which returns 100 in the case of failure and that clients have some compensation logic for that case. Imagine then that you modified the behavior of the method upon which those one thousand clients depend. The consequences are dramatic. I would pray for that developer who decided to introduce such a change, which breaks the code of one thousand clients.
The second danger is to modify the API’s signatures. Such modifications immediately break all the dependent clients. Believe me; clients will not thank you for such modifications. By the way, breaking changes may also cause ripple effects because there can exist hundreds of clients which depend on other clients which depend on your code.
Modifying the behavior by adding new code is actually conforms to what customers think when they ask for new features. When customers ask for a new feature they think that features will be added, they don’t think that developers will modify anything.
The idea might seem ridiculous from the first sight. Indeed, is that possible to write code that will never be modified again? Of course, this is not possible in practice. Well, theoretically it’s possible to write all the code in this way, but it is not practical. In programming, we often set some ideal goals which can’t be ever achieved. For example, we strive to write correct code, and theoretically, we can write code the correctness of which can be statically verified. Unfortunately, in 99% of cases, such development process is so expensive that no one is capable of investing so much money.
Another point which is obvious but I need to mention it is that we absolutely must modify the existing code if it contains a bug. So, bug fixing is OK from the Open/Closed principle point of view.
Mayer’s Definition of OCP
Let’s take a step back and look back into 1988. 1988 is the year when Bertrand Meyer described OCP for the first time. Meyer treated OCP a little bit differently than Uncle Bob. OCP from Meyer’s point of view was essentially an equivalent to the Protected Variation pattern. The Protected Variation pattern means the following: Identify points of predicted variation and create a stable interface around them. Talking here about an interface, we don’t mean the C# interface construct. An interface implies any API. A class has its own interface which is represented by its public API. We can notice here a subtle difference between Meyer’s and Martin’s definitions. Meyer’s definition is more about backward compatibility at the API level. So, if changes don’t break the backward compatibility, then OCP is met.
When exactly may we need to change the behavior without changing the interface? For example, when a new client appears which requires very similar but a little bit different behavior.
Single Choice Principle
The last point I want to address is the Single Choice Principle closely related to Open/Closed principle. I prefer to name this principle the “Single Source Principle” and you’ll understand why shortly. Let’s imagine the case when we need to decide which implementer to create depending on an argument. For solving such problems, we often rely on the Factory pattern.
Here is an example:
[code lang=”csharp”]
public class BankTerminalFactory
{
public static IBankTerminal CreateBankTerminal(BankTerminalModel model)
{
switch (model)
{
case BankTerminalModel.Brp:
return new BrpTerminal();
case BankTerminalModel.Dcp:
return new DcpTerminal();
default:
throw new ArgumentException("Unknown model");
}
}
}
[/code]
As you see here, we have a factory method which takes a parameter of BankTerminalModel enumeration. Depending on that parameter, the factory method creates a corresponding implementation of the IBankTerminal interface.
Does this factory method violate the Open/Closed principle? Of course, it does.
If we roll out a new implementation of the IBankTerminal interface, we will modify this code by adding a new case statement.
How can we solve the problem adhering the Open/Closed principle? We can create a factory of the BankTerminalFactory. But who will create that factory of this factory? Somewhere, in the end, we need to select the appropriate implementation and create the instance. We can partly solve the problem by introducing an IoC-Container, but this will just move the problem, it doesn’t solve the problem entirely.
If you’re not familiar with the notion of Dependency Injection and IoC-containers, as a side note, I’d recommend you a book which is an absolute bestseller written by Mark Seemann “Dependency Injection in .NET”.
So, the Principle of Single Choice sounds like this:
“Whenever a software system must support a set of alternatives, one and only one module in the system should know their exhaustive list.”
Yes, we’re violating the OCP by having such an implementation of the factory method, but there is no meaning to try to push the OCP further by any means. We should understand that in such cases all we need is to isolate such a responsibility in a single module and only that module should be changed in case of introducing new implementations.
Classic Violation
Here is an example of a classic OCP violation:
[code lang=”csharp”]
public string FindDevice(DeviceModel model)
{
SerialPort port = new SerialPort();
switch (model)
{
case DeviceModel.BillAccepterCashCode:
{
port.BaudRate = 9600;
port.Parity = Parity.Even;
port.Handshake = Handshake.RequestToSend;
return Find(port);
}
case DeviceModel.BillDispenserEcdm:
{
port.BaudRate = 4800;
port.Parity = Parity.Mark;
port.Handshake = Handshake.RequestToSendXOnXOff;
return Find(port);
}
case DeviceModel.CoinAccepterNri:
{
port.BaudRate = 19200;
port.Parity = Parity.Odd;
port.Handshake = Handshake.XOnXOff;
return Find(port);
}
case DeviceModel.CoinDispenserCube4:
{
port.BaudRate = 9600;
port.Parity = Parity.Space;
port.Handshake = Handshake.None;
return Find(port);
}
case DeviceModel.CoinDispsenerSch2:
{
port.BaudRate = 4800;
port.Parity = Parity.Even;
port.Handshake = Handshake.None;
return Find(port);
}
default:
throw new ArgumentException($"Unknown model: {model}.");
}
}
[/code]
We have a switch statement here to run the searching for a device method. In fact, if there is only one switch statement used with the DeviceModel enumeration, then the OCP is not violated. If switch statements used with that enumeration start to spread all over the code base then we have a violation of the OCP. That would be a violation since you couldn’t easily extend that enumeration. Adding a value to that enumeration would force you to change all the corresponding switch statements in the code base (handling new value). This example demonstrates the OCP violation from the Martin’s point of view. We don’t have any problems with this code from the Mayer’s point of view. At least yet.
Classic Fix
We firstly need to abstract away the Find method by extracting the IDevice interface.
[code lang=”csharp”]
public interface IDevice
{
string Find();
}
[/code]
Now we can implement it in different device models. Here is just one example:
[code lang=”csharp”]
public class CoinDispenserCube4 : IDevice
{
public CoinDispenserCube4()
{
Port = new SerialPort
{
BaudRate = 9600,
Parity = Parity.Space,
Handshake = Handshake.None
};
}
public SerialPort Port { get; }
public string Find()
{
foreach (string portName in SerialPort.GetPortNames())
{
//test if device is can be connected
Port.Write("special code");
if (Port.ReadByte() == 0)
return portName;
}
return null;
}
}
[/code]
Each device has to implement this interface. So, the internal details of searching for a concrete device are encapsulated in corresponding device classes.
The last piece I want to implement is an orchestrator class which accepts the IDevice interface and delegates the searching process to the IDevice implementer:
[code lang=”csharp”]
public class DeviceFinder
{
private readonly IDevice _device;
public DeviceFinder(IDevice device)
{
_device = device;
}
public string Find()
{
return _device.Find();
}
}
[/code]
If you have some common logic between devices, it might be beneficial to use an abstract class instead of an interface. All in all, we ended up with a design which allows us to roll out a new Device implementation without changing the code base, we just should add a new class which represents a device. So, our code is closed for modification and open for extension.
In both cases, we end up with multiple classes which inherit either from an abstract class or an interface.
Consequences
Such approach provides simpler extension if a new type is required, but it makes harder to add new operation since you’ll have to implement them in each class. As I’ve already said, there is always some kind of tradeoff involved. Welcome to engineering!
In this case, we harness the power of inheritance and composition. Client code, in this case, depends on abstraction. Client code should pass a plug into a model. The model itself doesn’t rely on inheritance; it relies on composition, it just stores the IDevice interface delegating to it appropriate responsibilities.
There is one little thing I want to mention before moving further. Do you think that applying that refactoring we took into account all the possible directions in which the system can grow? Not a chance!
That is a fundamental problem of predicting the future. Unfortunately, we can only assume what will happen in the future. That’s why customers always can find features which don’t fit into your design. Implementing them, you’ll have to redesign and rewrite parts of the system which already exist.
Does this fact make OCP and other principles unimportant? Does it mean that we can forget about any principles, in case we can’t predict the future anyway?
And here is the answer. Yes, unfortunately, we can’t achieve a super-supple design which allows to introduce any possible features without problems. We can only struggle to overcome the problem of unpredictable future. Developers came up with complex development methodologies the intent of which is to allow us to not bother so much about the problem of unpredictable future.
The first one is an old waterfall process, or the so-called “big design upfront”. This methodology suggests struggling with unpredictable future by trying to consider all the details of the project, creating all the possible abstractions, introducing extension points in all those places where a system can potentially be changed. The worldwide practice shows that in most cases adhering to the principles of this methodology leads to overengineering and thus to expenses which grow very fast.
The second methodology is called “Agile”. There are many agile methodologies nowadays, and we are not going to discuss them. I’ll only give a short description of what “Agile” is in essence. Agile methodologies are aimed at setting relationships between the development team and a customer. From the first day of a project, development team starts to work on a customer rolling out software by small increments. So, the Agile methodology suggests the iterative process of development with high involvement of a customer into the development process. In conjunction with SOLID principles and other best practices it allows to anticipate possible changes and react to them as quickly as possible by performing the refactoring. Of course, Agile doesn’t imply that a development team doesn’t design anything upfront. Agile is somewhere between two extreme points of no design up front and big design up front.
Of course, there are no techniques which can for sure protect you from all the possible changes. But the Agile process is how we’re trying to live with all that injustice in the modern days.
Common Smells
The most common smell of OCP violation is the appearance of many conditional branches either with if-else or with switch/case statements.
You have three common approaches to adhere to the OCP.
- Building a handling sequences with delegates which are naturally the implementations of the chain of responsibility pattern. Look for the Chain of Responsibility pattern. This pattern is amost built-in with events into the .NET platform. So, we use it when we need to pass a message and handle it by several handlers. The chain of responsibility provides the way to implement that in a loosely coupled manner. Otherwise you can end up with coupled objects.
- Another way is to rely on a classic inheritance with a template method pattern or to implement a Visitor pattern if you need to provide the ability for a client to define operations.
- The last way is to rely on a composition rather than on inheritance. Most likely you would rely on the Strategy pattern to implement a composition. Remember, that composition is generally more preferred than classic inheritance.
Conclusion
The open/closed principle is all about changes. Following OCP, you can achieve a very supple design which is ready for introducing new features without pain. To achieve the supple design, it should be open for extension and closed for modification. So, the most frequently changing parts of the system have to be isolated.
One very important thing is that we need to isolate a responsibility for creating objects in a single module and only that module should be changed in case of introducing new implementations. This principle is called the “Single Choice” principle.
You’ve also learned that we don’t have at our disposal any magic tools which allow us to protect ourselves from all the possible changes completely. And it doesn’t mean that we should start to create tons of extensions points anticipating changes in the future all over the code base. To overcome the problem, we’re trying to use the so-called “agile design.”
You can take the full video course “Software Architecture: Meta and SOLID Principles” with a huge discount clicking here.
Also, consider the option to become a patron on Patreon, thank you in advance!