SOLID principles and common misconceptions
I've learned about the popular SOLID principles early on in my career. I was delighted.
Finally, some guidelines I can refer to and pinpoint to my colleagues when building software instead of the usual subjective "in-my-opinion" arguments. And for the most part, these principles are quite solid (pun intended). They highlight the general high-level structure that well-built software should follow.
What I noticed though is that due to their popularity and the abundance of their 1-line summary everywhere, it's quite easy to misinterpret what each one means. What follows is what I witness to be the most common misconception for each of the principles. For the most part, I had most of these misconceptions until some more senior engineers helped to disambiguate the principle.
Single Responsibility
Each module should have one and only one reason to change.
This is usually treated as equal to "each module should have only one job" which sounds reasonable but is too general to be applied.
Instead, a way better explanation is "each module should be responsible to one, and only one, actor". This makes it clear that the module should not necessarily have a single job. But there should be a single actor/stakeholder which the module affects.
Example: In your system, some logic is defined by the sales department and some logic by the accounting department. You should never have logic required by both departments in the same module. When a department comes to you asking for a change, a single module should change.
Open-Closed
Modules should be open for extension but closed for modification.
At first glance, this screams to use inheritance to make modules open for extension. If some logic needs to change create a subclass and override methods as necessary.
But this creates a strong coupling between modules that creates other problems. Instead, abstraction and composition should be used to keep decoupled and open for extension.
Example: If you want to be able to calculate the area of multiple shapes in your system, do not hard code the area calculation in each shape (e.g. rectangle, circle, etc). This would require modifying the existing code to introduce a new shape. Instead, create a common interface with a calculateArea()
that each module will implement.
Liskov Substitution
Use interfaces/protocols to separate interchangeable parts.
This seems trivial at first. The compiler will enforce anyway that multiple classes that implement an interface do this correctly.
But this principle is more semantical/behavioral than syntactic. Any object suitable for substitution should do so without breaking the program.
Example: An interface Shape
with setWidth(w: Int)
and setHeight(h: Int)
indicates that the 2 properties are independent. A potential class Circle
implementing this interface would not be interchangeable without breaking the program since the 2 properties cannot change independently in this case.
Interface Segregation
A client should never be forced to implement an interface that it doesn’t use.
Makes sense, right? Do not depend on interfaces that you do not use. Just remove the dependencies on unused interfaces.
But this principle has more to do with how fine-grained the interfaces are. If your module is implementing an interface with methods that you do not use, just split the interface into multiple parts.
Example: Your class implements an interface Shape
with width()
, height()
and area()
. You throw UnsupportedOperationException
for area()
. Just split into 2 interfaces, ShapeDimensions
and ShapeArea
, and implement the first one only.
Dependency Inversion
Entities must depend on abstractions, not on concretions.
The name of this principle is super confusing on its own for a newcomer. No room for misunderstandings if you don't get anything out of it.
It turns out that in the old days of software development, it was considered best practice for a higher-level module to depend on a lower-level one. But by following this principle, high-level modules depend on even higher-level ones (i.e. the abstractions), hence the inversion.
Example: You have a TextGenerator
class that's using a ScreenPrinter
class to print on the screen. ScreenPrinter
is only printing on the screen. If you want to print on paper you would have to make changes to TextGenerator
. Instead, create an Writer
interface that SceenPrinter
will implement. Thus the TextGenerator
now depends on the even higher-level Writer
interface (abstractions).
Hopefully, this was useful and the principles are a bit clearer now :)