Why Even Bother?
In the software development world, there are 2 extremes.
- People who don't follow best practices.
- People who follow them to the extreme.
If you are lazy like me you mostly don't follow best-practices because YAGNI(You aren't gonna need it) but if you are like me you mostly do follow best-practices like SOLID Design Principles.
Wait. Why am I on both sides? Because I follow both depending on what I am doing. If it's simple, limited/scoped, and predictable then who needs to overthink about best practices but if it's complex, may-become-complex, should be scalable and maintainable then yeah, we need best practices.
If you are building a system that would need changes in the future then you are going to be happy about how SOLID can make your life easy.
What is SOLID?
SOLID is an acronym for 5 principles
- S. 👉 Single Responsibility
- O. 👉 Open/Close
- L. 👉 Liskov Substitution
- I. 👉 Interface Segregation
- D. 👉 Dependency Inversion
They aim to make your code manageable, maintainable, and scalable along with other benefits.
Note
They are not Rules, but best practices.
The Person Behind SOLID
This was in the year 2000. Robert C. Martin first introduced SOLID as a subset of different design principles in his paper Design Principles and Design Patterns.
Design principles and patterns are different, SOLID are principles.
So What Do The Principles Mean?
Each SOLID principle aims to achieve a certain goal by following a certain rule.
1. Single Responsibility Principle
It aims to separate behaviors or concerns. Meaning that every piece of code should have a specific purpose of existence and it should be used for that purpose only.
Example
The following function should only validate a user given their id.
function validateUser(userId){
// will validate user with their userId
}
For a complete reference, check out the Single Responsibility Principle in detail.
2. Open/Close Principle
The goal is to prevent those situations in which changing a piece of code from a module also requires us to update all depending modules. Basically, we don't allow new code to make changes to our old code.
We can extend code but not modify it. One real-life use case is of those softwares that have backward compatibility.
Example
A typescript example
interface PaymentMethod {
pay() : boolean
}
class Cash implements PaymentMethod {
public pay(){
// handle cash pay logic here
}
}
function makePayment(payMethod: PaymentMethod) {
if(payMethod.pay()){
return true;
}
return false;
}
In the above code if we want to add credit card payment all we have to do is add the following code (along with the actual implementation) and it will work just fine
class CreditCard implements PaymentMethod {
public pay(){
// handle credit pay logic here
}
}
For a complete reference, check out my other article on Open/Close Principle.
3. Liskov Substitution Principle
What this principle tells us is that if we replace an instance of a child class with a parent class, our code should still work fine without breaking or having side effects.
Example
class Printer{
function changeSettings(){
// common functionality
}
function print(){
// common functionality
}
}
class LaserPrinter extends Printer{
function changeSettings(){
// ... Laser Printer specific code
}
function print(){
// ... Laser Printer specific code
}
}
class _3DPrinter extends Printer{
function changeSettings(){
// ... 3D printer specific code
}
function print(){
// ... 3D printer specific code
}
}
This principle however has its limitations some of which I discussed in its own separate article. See Liskov Substitution Principle for an example of its limitations.
4. Interface Segregation Principle
This principle aim's to use role interfaces (or role modules in general) which are designed for a specific purpose and should only be used for those. It says
Clients Should Not Be Forced To Depend Upon Interfaces That They Do Not Use.
This principle solves some of the issues with the Interface Segregation Principle like the Bird example I mentioned in my article on Liskov Substitution Principle
Example
This is a typescript example but still not too difficult to understand.
interface BirdFly{
fly(): void;
}
interface BirdWalk{
walk(): void;
}
class Duck implement BirdFly, BirdWalk{
fly(){
// Duck can fly
}
walk(){
// Duck can walk
}
}
class Ostrich implement BirdWalk{
walk(){
// Ostrich can walk
}
}
For a complete reference, check out the Interface Segregation Principle in detail.
5. Dependency Inversion Principle
It focuses on using abstraction or facade/wrapper patterns to hide details of low-level modules from their high-level implementation.
We basically create wrapper classes that sit in between high-level and low-level modules. This helps a lot if the low-level implementations are different from each other.
Example
Again a typescript example
interface Payment {
pay(): boolean
}
// (Wrapper/Abstraction around cash payment)
class CashHandler implements Payment {
constructor(user){
this.user = user
this.CashPayment = new CashPayment();
}
pay(amount){
this.CashPayment.pay(amount)
}
}
// (low-level module)
class CashPayment {
public pay(amount){
// handle cash payment logic
}
}
// (High-level Module)
function makePayment(amount: number, paymentMethod: Payment){
if(paymentMethod.pay(amount)){
return true;
}
return false;
}
For a complete reference, check out the Dependency Inversion Principle in detail.
When To Use What And Avoid What
Now that we know a brief about each principle, we will look at when to use and avoid them.
Use | Avoid | |
---|---|---|
Single Responsibility | For Scalable and Maintainable Code. | When too much Fragmentation occurs without predictable future changes. |
Open Close | To prevent old code from breaking due to a new one. | When over-engineering. |
Liskov Substitution | Parent/Child used interchangeably without breaking. | When substitutions do not make sense. (Bird example) |
Interface Segregation | For Role-specific interfaces. | When difficult to aggregate (due to a lot of modules) from segregation. |
Dependency Inversion | For different low-level implementations. | When different implementations of a low-level module are not needed, like the String class in most languages are not changed because it's not needed to mostly. |
These are mostly the reasons and you can disagree but it all comes down to what you are dealing with.
Is SOLID Still Useful in 2021?
Ask yourself. Is there a language that does everything with one line of code?
do_everything();
I guess not, unless you or someone makes a language that uses less code than python and does everything with one line of code, you do need SOLID design principles.
Of course, there are extremes and cases where it's just not possible to implement SOLID, but if you are comfortable and can use SOLID then you probably should.
Conclusion
So, what's your take on this? Do you follow an approach similar to mine? Be sure to give this article a 💖 if you like it.
Top comments (16)
My opinion is that these 5 principles are not all on the same "level". Some of them seem quite 'specific' - heavily geared towards classical OO (think Java, or C++).
The one that I think is most useful, still, in 2021, is the most generic one (and the one that's least tied to "classic OO"):
"Single Responsibility"
I'd say that this one is (almost) always useful, regardless of the programming language or paradigm you're using (like OO, FP, and so on).
The more a principle is tied to the specific "classical OO" paradigm, the least relevant it still is in 2021. Well that's what I think :-)
These idea still apply in FP JS:
Every time we call connect() to wire up a React component to Redux or similar, we’ve just practiced dependency injection (props are injected dependencies) & single responsibility (component is not responsible for sourcing its own data).
Similarly, passing in callback functions to a component is following Open/Closed, as you’ve extended without modifying.
Great examples!
Agreed well said.
As a FE developer, when I first learnt these principles in terms of real world use cases many years ago, it totally blew my mind.
I try to follow them as much as possible and I think my code has vastly improved as a result.
The front end ecosystem lends itself more to FP than OOP these days so much of the original SOLID descriptions are not really relevant, but there are ways to interpret them.
SRP and DI in particular are incredibly important principles that I take into consideration in everything I do.
"we should be able to use a child and a parent class interchangeably" - no, not interchangeably. Liskov only states that a subclass should be usableable instead of the parent. Not necessarily the other way around. It's perfectly acceptable to add a method in a subclass.
In fact it's right there in the article you linked.
Updated.
... I initially started by writing an answer that they are relevant and should be used if your language supports them. But I think it's more subtle than that.
For example, in front end, it's rare to apply these principles. Dependency injection seems rare (outside of the Angular framework). Hardcoded imports and usage of dependencies seems much more common. This breaks the open-closed principle in many cases.
Interfaces are not used either (JavaScript doesn't have them). Even when using TypeScript, interfaces are often used because they must be used for type checking, rather than to apply the dependency inversion principle.
But maybe it's okay. Each layer of abstraction has benefits, but also increases complexity. If the benefits aren't worth it, then maybe it's okay to ignore them. Dependency injection and interfaces aren't as useful if there is only ever a single implementation. There are ways to unit test without them and having to recompile isn't too bad.
But, my personal preference is to use them. I prefer Angular in this aspect compared to other front end frameworks. So in the end, I agree for the most part. I'd say to always use the single responsibility principle and the open-closed principle as much as possible. As for the rest, be pragmatic and start using them early if they'll provide a real benefit to the project.
I think the SOLID principles are just as relevant now as they’ve ever been, and FP languages still benefit from applying the values behind them.
The thing a lot of developers miss about SOLID: they’re about useful shapes for indirection & abstraction: they can’t tell you whether adding abstraction or indirection is the correct path to take in your setting & context.
As long-time OOer and modeling guru Sandi Metz says, “the wrong abstraction is more costly than no abstraction at all.”
And it’s precisely because introducing abstraction & indirection bear design cost that the SOLID principles are useful & relevant!
So:
Part 4 : a class do not extends interfaces but classes. Typo of extends
My bad, I will fix it. Thanks 👍
well written, well structured. thanks for this!
Yangi => yagni
SOLID is very old by now, better give it a check to WET -> deconstructconf.com/2019/dan-abram...