SOLID: How to create long-life software
“A good developer writes code for a machine to read, a great developer writes code for a human being to read.” — I read/listened to this expression somewhere that I don’t remember anymore but means a lot from my point of view. It is so satisfying/productive when you get a code to work on and it is readable, isn’t it? But how can we write a code like that? More than that, an extensible, maintainable, adaptive, agile, and refactorable code. It is not a simple question to answer and without a single answer at all but something that will help you with that, for sure, is SOLID.
SOLID is an acronym for five object-oriented design principles written by Robert C. Martin that helps you write code with all the adjectives listed above. Those are kinds of rules that the software development community adopted as good practices while developing software, worldwide known and recognized. Let’s deep dive then!
S — Single Responsibility Principle
“A CLASS SHOULD HAVE ONLY ONE REASON TO CHANGE”
This one can be tricky! Seems to be easy but can be more abstract than it should be. This principle says, as it is already in its name, your class should have only one single responsibility and no more than that. But, what is a responsibility? That is the part where it becomes interesting, it depends! Let’s take some piece of code to show it up.
Let’s consider we are developing a Car Wash software to schedule appointments and help organize things. Now we have two classes, Car.java and CarWashAppointmentScheduler.java as follows.
Great! Our code works but, let’s imagine now that we have more types of leather seats, have cowhide leather, microfiber leather and, PVC. Ok, it is easy.
But is it the responsibility of our scheduler to decide what is or what isn’t a leather seat? What is more likely to be the responsibility of our scheduler? To decide what is or isn’t a leather seat or group all information we need and save it in the DB? To follow the Single Responsibility Principle, I propose to create a specific validation class that will bring directly this information to us, something like this:
And our scheduler becomes like this:
So, the CarWashAppointmentScheduler will change only if any logic of the scheduler itself changes, not if a new Leather Seat starts to be produced. If so, we will only change the CarChecker class.
This principle helps us to get our code organized and easy to track, make changes, and test it. If I need to change something, I can go directly to the point of the change without browsing all through the software or wondering where it could be used.
O — Open-Closed Principle
“OBJECTS OR ENTITIES SHOULD BE OPEN FOR EXTENSION BUT CLOSED FOR MODIFICATION”
As our software grows, the specifications change and new features are added. This principle says that our system needs to be capable of extension behavior without modifying the previous one.
Using the same example that Robert C. Martin used in his book Clean Code but simplified, let’s imagine we have a class called Sql, responsible for generating Sql statements.
Perfect, that worked for a while but now we need more complex SQL statements, having “where”s, “inner join”s, and so on. Every new statement we are supposed to add to our SQL generator will require a change in this class. To avoid this, we can follow the Open-Close Principle and we extend this class and open it for every extensibility we need.
Making our Sql class as an abstract one we could extend this and add features as needed.
In this way, we can guarantee the previous behavior and could add features that our software needs. This means, our software is closed for modification but open for extension, which is a very powerful tool to avoid regressions on our software.
L — Liskov Substitution Principle
“LET Q(X) BE A PROPERTY PROVABLE ABOUT OBJECTS OF X OF TYPE T. THEN Q(Y) SHOULD BE PROVABLE FOR OBJECTS Y OF TYPE S WHERE S IS A SUBTYPE OF T”
Trying to translate it in easy words, means that an instance of a subclass must be used the same as an instance of the superclass. Ok! But, my typed, object-oriented programming language already does it for me. Not necessarily! That means as well that, your application should maintain the same behavior either using a subclass instance or superclass instance. So, does it say that I am not supposed to create specialized subclasses from an abstract superclass? Where then should I use the polymorphism from OO?
For this principle, we have three mainly ways to break it:
- returning a different type of the superclass (which OO strongly-typed programming languages already prevent us from doing)
- throwing an unexpected exception (Java prevents us from doing that with the exception signature
- introducing unexpected side effects
In this example, we have a superclass called CarBoardComputer that is inherited by those other subclasses DieselCarBoardComputer and HybridCarBoardComputer. (for this one, I will use c# because Java has the exception signature on their methods making this example scenario impossible)
As you can see, we are using polymorphism and it is breaking the Liskov Substitution Principle but not because the calculation is different from the superclass. Can you point out where the principle has been broken?
We do have a different behavior comparing both DieselCarBoardComputer and HybridCarBoardComputer but the issue here is not the difference between the calculation types. The problem is that, at a certain point the HybridCarBoardComputer could raise an exception that is not expected considering the superclass implementation. In the example below, if inside the carsInfo list, we have a HybridCarBoardComputer which has wattsLeftOnBaterry property with 0 we will break the expected software behavior and that is where we are breaking the Liskov Substitution Principle.
Changing the code like below, our code starts to be aligned with the Liskov Substitution Principle. Because now, if we pass a HybridCarBoardComputer instance instead of either CarBoardComputer or DieselCarBoardComputer, the result will be different but the behavior will always be the same.
In this way, we can prevent unexpected behaviors from our application even if we have a development error hidden in our code and can keep using the polymorphism as well. Strongly-typed with OO support programming languages help us a lot on this principle but we still have a piece to take care of to not cross the line.
I — Interface Segregation Principle
“CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE”
This principle says that, if some client/class is overriding a method to let it blank, something is wrong. It goes against those huge/fat interfaces that are supposed to cover everything but in the end, it makes us lose the cohesion of the code. Let’s see an example where this principle is not respected.
In this scenario, we have a software responsible for sending alerts to our customers. In the beginning we were supposed to send the alert internally through our software only, so we had a very simple code, where every type of alert implements the ISendable interface as below.
This interface is implemented by all kinds of alerts we have on our software, it only contains a declaration of a method called send, responsible for sending the alert. And the code below shows two examples of alerts our software emits nowadays.
But, what if we receive a demand to send some alerts through SMS as well? We could put a new method signature on the ISendable interface and implement those alerts we want to send through SMS. And as the demand is only for Insufficient disk space alert we implemented nothing on the Low Throughput alert, we just created the method to have all methods required by the ISendable interface.
And at this point, we are violating the principle. Why is the class LowThroughPutAlert forced to implement a method that it doesn’t use? But we need to send alerts by Sms and I want to link those alerts to the ISendable interface because they are actually sendable. The idea of this principle is to segregate the interfaces, as the name already says, the idea is to create as many interfaces as we need. A way to cover our new demand and still be aligned with this principle is the following.
The ISendable interface goes back to the single method sendInternally. And we have a new one.
We created a new and more specific interface called ISmsSendable that implements the previous interface ISendable as well. In this way, every class that implements the new interface ISmsSendable is required to implement both sendBySms and sendInternally. And those classes that implement ISendable need to implement methods contained on that interface only and not more than that.
The LowThroughPutAlert is kept just implementing the method sendInternally as we expect this alert only be sent internally and does not implement unused methods anymore. And the InsufficientDiskSpaceAlert was modified to the following.
As the ISmsSendable interface extends the ISendable, the InsufficientDiskSpaceAlert is still an ISendable, in this way, it is required to implement all methods contained in both interfaces. Our code is now aligned to the Interface Segregation Principle avoiding classes to implement non-used methods, and maintaining the cohesion of the code as it should be.
D — Dependency Inversion Principle
“ENTITIES MUST DEPEND ON ABSTRACTIONS, NOT ON CONCRETIONS. THE HIGH-LEVEL MODULE MUST NOT DEPEND ON THE LOW-LEVEL MODULE, BUT THEY SHOULD DEPEND ON ABSTRACTIONS”
In simple words, it says that our module must depend on interfaces instead of on concrete classes because in this way we uncouple the low-level implementation from our code. It makes it easy when we need to change from one component to another like the DB connector implementation or a queue tool, and this last one will be our example for this topic.
Our software uses a queue tool called RabbitMQ, on-premise, as the software is too. The software is responsible for reading temperature from some sensor around a facility and sending it for further processing and data analysis. As our executive director is aware of the “cloud wave” we started to migrate our software to the cloud and we are responsible for this first step which is to change our queue tool from RabbitMQ on-premise to the Azure Service Bus. Now our code looks like the following.
The class TemperatureReader receives an instance of our RabbitMQService class which is responsible for sending and dealing with all about our queue tool (RabbitMQ).
As we discussed before, now we need to change from our on-premise RabbitMQ service to a new one on the cloud, Azure Service Bus. For a while it looks easy, we just need to change from RabbitMQService to a new one AzureServiceBusService. But if we use this tool all around the software, through a lot of different flows, how do we track all of them, and change to another one? It is hard because we have a high level of coupling between our modules, our high-level module is depending on a low-level module. Considering this principle and based on our issue, the changes I would recommend to this code are the following.
We create a new interface, responsible for holding the signature of methods related to a queue tool that we don’t know about the implementation yet. Our TemperatureReader class now receives an instance of a class that implements the new interface. At this point, we don’t care how it is implemented or even where it is going, if it is an on-premise RabbitMQ/Kafka or one cloud-based service such as the Azure Service Bus. After that, we just need to change our RabbitMQService to implement this new interface and/or implement a new concrete class to send the messages to the Azure Service Bus, and switch between them during the start-up of our software regarding our needs.
We stop depending on concrete classes and start depending on signatures/interfaces for which we don’t care where and how they are implemented, to focus on the business rule only. In the end, our class TemperatureReader doesn’t know which queue tool it is being used, and actually, it doesn’t need to know. With those changes and following this principle, changes like the one in the example became transparent for our code, making our software versatile, easy to change, and trackable.
Conclusion
In this article, we went through all five principles of SOLID explaining step-by-step the issue and how to implement the solution based on each principle. It is not necessary to implement all of them on your software but, of course, more is better. Those principles will help you have a better software code base, easier to change, and have healthy growth over time. And don’t forget, it is not a work with an end, every day you have to keep it in mind to not start going down the wrong path again.
I tried to explain in a different way and with my own words those five principles, to help you improve your knowledge about this topic and your daily code. I hope you enjoyed it! Any questions or comments, please feel free to contact me. See ya in the next article! Bye!