Solid Principles
This article aims to give a solid explanation of SOLID Principles and give some insight on their benefits and potential issues when applying them. Let’s go through each of them briefly.
S — Single Responsibility Principle(S.R.P)
A class should have one, and only one, reason to change.
When requirements change, this implies that the code has to undergo some reconstruction, meaning that the classes have to be modified. The more responsibilities a class has, the more change requests it will get, and the harder those changes will be to implement. The responsibilities of a class are coupled to each-other, as changes in one of the responsibilities may result in additional changes in order for the other responsibilities to be handled properly by that class.
- What is a responsibility?!
A responsibility can be defined as a reason for change. Whenever we think that some part of our code is potentially a responsibility, we should consider separating it from the class. Let’s say we are working on a project that helps people become more active in their community, and the system needs to have social media integration. It would be a good idea to separate the social media integration responsibility from the other parts of the system, as we should always be prepared for external changes.
If you want to see SRP in action, check out this article about PORO(Plain Old Ruby Objects), which explains how SRP is implemented in Ruby on Rails applications to make the codebase scalable and maintainable.
Have you ever heard of something being Open and Closed at the same time?! Well, I certainly have, so lets take a look at it together.
O — Open-Closed Principle
You should be able to extend a class’s behavior, without modifying it.
This principle is the foundation for building code that is maintainable and reusable.
Robert C. Martin
How can something be open and closed?! A class follows the OCP if it fulfills these two criteria:
- Open for extension
This ensures that the class behavior can be extended. As requirements change, we should be able to make a class behave in new and different ways, to meet the needs of the new requirements.
- Closed for modification
The source code of such a class is set in stone, no one is allowed to make changes to the code.
How do we achieve this?
Through abstractions. In order to be able to extend the behavior of a class without changing a single line of code, we need to make abstractions. For example, if we had a system that works with different shapes as classes, we would probably have classes like Circle, Rectangle, etc. In order for a class that depends on one of these classes to implement OCP, we need to introduce a Shape interface/class. Then, wherever we had Dependency Injection, we would inject a Shape instance instead of an instance of a lower-level class. This would give us the luxury of adding new shapes without having to change the dependent classes’ source code.
How do we know whether to make Shape a class or an interface? For that, we’ve got the Liskov Substitution Principle, which tells us when inheritance is suitable. Let’s take a look, shall we?
L — Liskov Substitution Principle
Derived classes must be substitutable for their base classes.
What is wanted here is something like the following substitution property: If
for each object o1 of type S there is an object o2 of type T such that for all
programs P defined in terms of T, the behavior of P is unchanged when o1 is
substituted for o2 then S is a subtype of T.
Barbara Liskov
Let’s visualize the definition with a case study. Let’s say we have a Rectangle class, and we have a class that extend it, Square. Let’s also say that Rectangle has two methods, setWidth and setHeight, which, well, set the width and height of the rectangle respectively.
The problem is that the behavior for the two methods differs between the Rectangle and the Square classes. The reason for that is that a Square, by mathematical definition is a Rectangle with equal height and width. So, the two methods will change the same value, whereas for the Rectangle, they will change the width and height respectively, which are different values from each other.
When we are using abstraction(Open-Closed Principle), we want the methods to behave the same for each derived class, and not differently. In this case, we can clearly see that a Square class should not be extending the Rectangle class, because the behavior of the inherited methods differs.
- The solution
As Robert C. Martin suggests here, we should design by contract. What this means is that each method should have preconditions and postconditions defined. Preconditions must hold true in order for a method to execute, and postconditions must hold true after the execution of a method.
…when redefining a routine [in a derivative], you may only replace its
precondition by a weaker one, and its postcondition by a stronger one.
Bertrand Meyer
This is a clear and to-the-point explanation of this definition that I found online:
- Assume your baseclass works with a member int. Now your subtype requires that int to be positive. This is strengthened pre-conditions, and now any code that worked perfectly fine before with negative ints is broken.
- Likewise, assume the same scenario, but the base class used to guarantee that the member would be positive after being called. Then the subtype changes the behavior to allow negative ints. Code that works on the object (and assumes that the post-condition is a positive int) is now broken since the post-condition is not upheld.
Robert C. Martin suggests that it is helpful to document(comments) the preconditions and postconditions for each method.
I — Interface Segregation Principle
Make fine grained interfaces that are client specific.
Clients should not be forced to implement interfaces they do not use.
Robert C. Martin
In other words, it is better to have many smaller interfaces, than fewer, fatter interfaces.
For example, let’s say we had an interface called Animal, which would have eat, sleep and walk methods. This would mean that we have a monolithic interface called Animal, which would not be the perfect abstraction, because some animals can fly. Breaking this monolithic interface into smaller interfaced based by role, we would get CanEat, CanSleep and CanWalk interfaces. This would then make it possible for a species to eat, sleep and for example fly. A species would be a combination of roles, instead of being characterized as an animal, which would not necessarily be the best description. At a larger scale, microservices are a very similar case, they are pieces of a system separated by responsibilities, instead of being a great monolith.
By breaking down interfaces, we favor Composition instead of Inheritance, and Decoupling over Coupling. We favor composition by separating by roles(responsibilities) and Decoupling by not coupling derivative classes with unneeded responsibilities inside a monolith.
D — Dependency Inversion Principle
Depend on abstractions, not on concretions.
A. High level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.
Robert C. Martin
Let’s say we have a system that handles authentication through external services such as Google, GitHub, etc. We would have a class for each service: GoogleAuthenticationService, GitHubAuthenticationService, etc. Now, let’s say that some place in our system, we need to authenticate our user. To do that, as mentioned, we have several services available. To be able to make use of all the services, we have two possibilities: We either write a piece of code that adapts each service to the authentication process, or we define an abstraction of the authentication services. The first possibility is a dirty solution that will potentially introduce technical debt in the future; in case a new authentication service is to be integrated to the system, we will need to change the code, which as a result violates the OCP. The second possibility is much cleaner, it allows for future addition of services, and changes can be done to each service without changing the integration logic. By defining a AuthenticationService interface and implementing it in each service, we would then be able to use Dependency Injection in our authentication logic and have our authentication method signature look something like this: authenticate(AuthenticationService authenticationService). Then, we could authenticate by a specific service like this: authenticate(new GoogleAuthenticationService). This helps us generalize the authentication logic without having to integrate each service separately.
By depending on higher-level abstractions, we can easily change one instance with another instance in order to change the behavior. Dependency Inversion increases the reusability and flexibility of our code.
Benefits
Following principles always has benefits. It is no different in software engineering. Following the SOLID Principles gives us many benefits, they make our system reusable, maintainable, scalable, testable and more. For more about the benefits of each of these principles, make sure to read Uncle Bob’s articles.
For a fun and simple explanation of the SOLID principles, check out these photos.
Comments
Post a Comment