This is the second post of a series dedicated to the use of SOLID principles at Codavel. Building upon the philosophy of “make it work, make it right, make it fast”, we mostly employ these principles after having an MVP. Why? An MVP will always give us valuable information about the extent to which an abstraction will be useful. Not only that, but it will also guide you in understanding "how and where" to apply SOLID.
Before we go through another principle, you should already know that:
- Requirements may change over time: there is no clear way to fully predict the features your product will need.
- Abstraction predicaments may fail: there is no bullet-proof way to ensure which types of abstractions you will require in the future.
With that in mind, let's recall the five SOLID principles:
- Single responsibility principle (SRP)
- Open / closed principle (OCP)
- Liskov substitution principle (LSP)
- Interface segregation principle (ISP)
- Dependency inversion principle (DIP)
Today, I'll be talking about the second principle.
The second SOLID principle: Open / Closed Principle
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
What does the second SOLID principle mean? It means that developers have two extra responsibilities.
First, when you write some module, you need to think of it as a component whose implementation will not change (it’s immutable!). That means it must have a perfect behavior. Second, you need to find proper abstraction to accommodate any required changes for your modules (such as adding features or changing some business logic).
Think about a client that uses some plugin or API that is not your latest version to communicate with your servers. Any feature changes that you introduced in your latest version should not break backward compatibility, so the client can still communicate with the server. The Open / Closed principle (OCP) is all about this, how you should develop modules for which you can easily extend logic.
If your design does not follow the Open / Closed Principle, chances are that you’ll have trouble when trying to support multiple versions of the same modules and you will probably end up having to deal with a large amount of duplicate code.
How can I implement it?
There are multiple ways to implement the Open / Closed principle, the two most famous being inheritance and composition. Inheritance works by having a class derived from another class (hierarchically), in which they share attributes and methods. The newly derived class can extend the behavior of its parent class either by adding new attributes/methods or by overriding methods of the parent class.
Composition is a different concept, more design-oriented: classes reference other classes by instantiating objects or attributes of another class in its own members. I personally opt to use composition, since it generally allows for a cleaner design and better readability, might expose you more to violations of the SRP and OCP and helps you have a better grasp of how you should go for your problem space decomposition.
What are the main benefits?
The most obvious benefit is the ability to extend a component’s logic without breaking backward compatibility, but there are others. In our case, OCP is really useful to test different component implementations (that have the same logic) against each other.
Think about a reliable transport protocol: it usually works by extracting some information upon packet reception, computing some network metrics, applying some networking rules, and deciding how many packets can be sent. This logic is fairly similar, whether you use a packet-loss, delay or bandwidth-based transport protocol. So you can actually create an abstraction that allows for a particular implementation of multiple protocols, that you can easily switch in between.
Abstract, abstract, abstract
When our team is discussing some feature or component, we generally end up with a bunch of ideas that could actually make it work. Then, we commit to one (which may not be the best!). However, we make an effort to formalize an abstraction where the other ideas might fit as a different implementation. This way, we end up with a flexible design of the component that allows for these competing ideas to be easily implemented.
Caveat: initial abstractions will never be able to accommodate all future requirement changes.
One thing I’ve learned is that interface definitions of your core product features should actually be a team effort.
First, because your teammates “can be your first clients” and can guide you to what they would change in your design. This signal can actually be interpreted as “I will probably need to change this class in the future”, so try to make an abstraction for it.
Second, because they can throw you a bunch of possible implementations that will help you define the scope that your abstraction should have (and please, do not go further than that!).
Lastly, because they are probably developing some feature that will use your component as a dependency. This means they can guide you through what parts of the product are more likely to change in the future.
Thus, let me end this with a simple advice: discuss designs regularly among your team.
Happy abstractization!