Before we jump onto the topic of SOLID design principles, you must understand why we need them in the first place. If you are hearing the term, SOLID, for the first time then sit tight as you’ll learn a whole new way of designing your classes.
What problems we are trying to solve using SOLID principles?
Let me try to answer the most important question.
How many of you have been slowed down by really bad code? All of us at some point.
If we know that bad code slows us down then why do we write bad code?
We do it as we had to go fast ….and let that sink in.
As Uncle Bob, Robert C. Martin says
You don’t go fast by rushing.
You don’t go fast by just making it work and releasing it as fast as you can.
You wanna go fast? You do a good job.
You sit carefully, think about the problem, type a little, clean it up and repeat. That’s how you go fast.
What are the symptoms of bad code?
- Code Rigidity
Code that has dependencies in so many directions that you can’t make a change in isolation.
You change one part of the code and it breaks the calling/dependant class and you have to fix it there. In the end, because of that one change, you end up making changes in 10 different classes. - Code Fragility
When you make a change and an unrelated piece of code breaks. - Tight Coupling
It happens when a class depends on another class.
If you can relate to any of the above issues, then this article is for you!
In this article, we will learn how to overcome these issues using SOLID design principles.
So, Why do we need them?
We need them to write
- Flexible Code
- Maintainable Code
- Understandable Code
- Code can tolerate changes
What are SOLID principles?
SOLID is an acronym that stands for 5 design principles.
- S — Single Responsibility Principle (SRP)
- O — Open/Closed Principle (OCP)
- L — Liskov Substitution Principle (LSP)
- I — Interface Segregation Principle (ISP)
- D — Dependency Inversion Principle (DIP)
S — Single Responsibility Principle (SRP)
A module should have one, and only one reason to change.
What is a module?
The simplest definition is just a source file.
Some languages and development environments, though, don’t use source files to contain their code. In those cases, a module is just a cohesive set of functions and data structures.
Source: Clean Architecture, Robert C. Martin
Before understanding how SRP is followed/implemented/used, we should understand how it is not used.
Violation of SRP
class Order { | |
fun sendOrderUpdateNotification() { | |
// sends notification about order updates to the user. | |
} | |
fun generateInvoice() { | |
// generates invoice | |
} | |
fun save() { | |
// insert/update data in the db | |
} | |
} |
Can you spot the violation?
The violation is that the Order
handles more than one responsibility which means it has more than one reason to change.
Solution
Create an Order
which is responsible for holding the order data.
data class Order( | |
val id: Long, | |
val name: String, | |
// ... other properties. | |
) |
Create OrderNotificationSender
which is responsible for sending notification updates to the user.
class OrderNotificationSender { | |
fun sendNotification(order: Order) { | |
// send order notifications | |
} | |
} |
Create OrderInvoiceGenerator
which is responsible for generating the order invoice.
class OrderInvoiceGenerator { | |
fun generateInvoice(order: Order) { | |
// generate invoice | |
} | |
} |
Create OrderRepository
which is responsible for storing the order in the database.
class OrderRepository { | |
fun save(order: Order) { | |
// insert/update data in the db. | |
} | |
} |
We have extracted different responsibilities from the Order
class into separate classes and each one of them has a single responsibility.
Optionally you can even go a step further and create a OrderFacade
which delegates the responsibilities to the individual classes.
class OrderFacade( | |
private val orderNotificationSender: OrderNotificationSender, | |
private val orderInvoiceGenerator: OrderInvoiceGenerator, | |
private val orderRepository: OrderRepository | |
) { | |
fun sendNotification(order: Order) { | |
// sends notification about order updates to the user. | |
orderNotificationSender.sendNotification(order) | |
} | |
fun generateInvoice(order: Order) { | |
// generates invoice | |
orderInvoiceGenerator.generateInvoice(order) | |
} | |
fun save(order: Order) { | |
// insert/update data in the db | |
orderRepository.save(order) | |
} | |
} |
As we can see that each class has a single responsibility, thus following the Single Responsibility Principle.
O — Open/Closed Principle ( OCP)
The OCP was coined in 1988 by Bertrand Meyer as
A software artifact should be open for extension but closed for modification.
In other words, the behavior of a software artifact ought to be extendible without having to modify that artifact.
Source: Clean Architecture, Robert C. Martin
Violation of OCP
To understand the violation of OCP, let’s take an example of a Notification Service that sends different types of notifications — Push Notifications and Email Notifications to the recipients.
enum class Notification { | |
PUSH_NOTIFICATION, EMAIL | |
} |
class NotificationService { | |
fun sendNotification(notification: Notification) { | |
when (notification) { | |
Notification.PUSH_NOTIFICATION -> { | |
// send push notification | |
} | |
Notification.EMAIL -> { | |
// send email notification | |
} | |
} | |
} | |
} |
Let’s say that I get a new requirement and we now support SMS Notifications, which means I have to update the Notification
enum and the NotificationService
to support SMS notifications.
So, the Notification
and NotificationService
will be like this
enum class Notification { | |
PUSH_NOTIFICATION, EMAIL, SMS | |
} |
class NotificationService { | |
fun sendNotification(notification: Notification) { | |
when (notification) { | |
Notification.PUSH_NOTIFICATION -> { | |
// send push notification | |
} | |
Notification.EMAIL -> { | |
// send email notification | |
} | |
Notification.SMS -> { | |
// send sms notification | |
} | |
} | |
} | |
} |
This means that every time we change the notification type, we will have to update the NotificationService
to support the change.
This is a clear violation of the OCP. Let’s see how you can abide by the OCP.
Solution
Create an interface Notification
.
interface Notification { | |
fun sendNotification() | |
} |
Create the implementations of the Notification
of each type — PushNotification
, and EmailNotification
.
class PushNotification : Notification { | |
override fun sendNotification() { | |
// send push notification | |
} | |
} |
class EmailNotification : Notification { | |
override fun sendNotification() { | |
// send email notification | |
} | |
} |
Create NotificationService
.
class NotificationService { | |
fun sendNotification(notification: Notification) { | |
notification.sendNotification() | |
} | |
} |
Now, your NotificationService
follows OCP as you can add/remove different types of notifications without modifying the NotificationService
.
Create SMSNotification
which implements Notification
.
class SMSNotification : Notification { | |
override fun sendNotification() { | |
// send sms notification | |
} | |
} |
Job Offers
As you can see, I have added SMSNotification
without modifying NotificationService
thus following the Open/Closed Principle.
Side Note:
This is the one principle that is really hard to follow and one can’t fully abide by it only in an ideal world.
As 100% closure is not attainable, the closure must be strategic.
L — Liskov Substitution Principle (LSP)
In 1988, Barbara Liskov wrote the following as a way of defining subtypes.
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.
In other words, it means that the child type should be able to replace the parent without changing the behavior of the program.
Let us try to understand the principle by seeing the violation of the infamous Square/Rectangle problem.
Violation of LSP
We know that a rectangle is a polygon with 4 sides where opposite sides are equal and are at 90°.
A square can be defined as a special type of rectangle that has all sides of the same length.
If squares and rectangles were to follow LSP then we should be able to replace one with the other.
Please Note: The
Square
and theRectangle
are written in Java as Kotlin code would clearly show the violation without me proving it
Create a Rectangle
public class Rectangle { | |
private int height; | |
private int width; | |
public void setHeight(int height) { | |
this.height = height; | |
} | |
public void setWidth(int width) { | |
this.width = width; | |
} | |
public int area() { | |
return height * width; | |
} | |
} |
Create Square
public class Square extends Rectangle { | |
@Override | |
public void setHeight(int height) { | |
setSide(height); | |
} | |
@Override | |
public void setWidth(int width) { | |
setSide(width); | |
} | |
private void setSide(int side) { | |
super.setHeight(side); | |
super.setWidth(side); | |
} | |
} |
Create Driver
to execute the flow.
fun main() { | |
val rectangle = Rectangle() | |
rectangle.setHeight(5) | |
rectangle.setWidth(2) | |
val rectangleCheck = rectangle.area() == 10 // true | |
val square: Rectangle = Square() | |
square.setHeight(5) // width is also set to 5 | |
square.setWidth(2) // height is also set to 2 | |
val squareCheck = square.area() == 10 // false - not substitutable | |
} |
In the above code, Driver
we can clearly see that Rectangle
and Square
cannot replace each other. Hence, LSP is clearly violated.
Under no circumstances the above problem will follow LSP. So, for the solution/example of the LSP, we will look at another problem.
Example of LSP
Let’s consider a Waste Management Service which processes different types of waste — Organic waste and Plastic waste.
Create Waste
interface
interface Waste { | |
fun process() | |
} |
Create OrganicWaste
and PlasticWaste
which implements Waste
interface.
class OrganicWaste : Waste { | |
override fun process() { | |
println("Processing Organic Waste") | |
} | |
} |
class PlasticWaste : Waste { | |
override fun process() { | |
println("Processing Plastic Waste") | |
} | |
} |
Create WasteManagementService
class WasteManagementService { | |
fun processWaste(waste: Waste) { | |
waste.process() | |
} | |
} |
Create LSPDriver
fun main() { | |
val wasteManagementService = WasteManagementService() | |
var waste: Waste | |
waste = OrganicWaste() | |
wasteManagementService.processWaste(waste) // Output: Processing Organic Waste | |
waste = PlasticWaste() | |
wasteManagementService.processWaste(waste) // Output: Processing Plastic Waste | |
} |
In the LSPDriver
we can clearly see that we are able to replace different types of wastes, i.e Organic and Plastic, with each other without affecting the behavior of the program. Thus following the Liskov Substitution Principle.
I — Interface Segregation Principle (ISP)
The Interface Segregation Principle states that developers shouldn’t be forced to depend upon the interfaces that they don’t use.
In other words, the class that implements the interface shouldn’t be forced to use the methods it does not need.
Violation of ISP
Let’s assume that we are building a UI library that has components and the components can have different UI interactions like Click events – single-click and long-click.
We have an interface OnClickListener
that has different click behaviors, in order for a UI component to have this behavior, it must implement the OnClickListener
interface.
Create OnClickListener
interface OnClickListener { | |
fun onClick() | |
fun onLongClick() | |
} |
Create CustomUIComponent
class CustomUIComponent : OnClickListener { | |
override fun onClick() { | |
// handles onClick event. | |
} | |
// left empty as I don't want the [CustomUIComponent] to have long-click behavior. | |
override fun onLongClick() { | |
} | |
} |
We can clearly see that the CustomUICompoenent
is forced to override onLongClick
method even though as per the requirements we don’t want the CustomUICompoenent
to have long click behavior.
This is a clear violation of the LSP.
Solution
This solution is straight-forward, we can separate the OnClickListener
interface into two different interfaces — OnClickListener
and OnLongClickListener
, they handle single-click behavior and long-click behavior respectively.
Create OnClickListener
interface OnClickListener { | |
fun onClick() | |
} |
Create OnLongClickListener
interface OnLongClickListener { | |
fun onLongClick() | |
} |
Create CustomUICompoenent
which implements OnClickListener
class CustomUIComponent : OnClickListener { | |
override fun onClick() { | |
// handle single-click event | |
} | |
} |
Now the CustomUIComponent
is not forced to override onLongClick
method. Hence following the Interface Segregation Principle.
D — Dependency Inversion Principle (DSP)
The Dependency Inversion Principle states that the most flexible systems are those in which code dependencies refer only to abstractions, not to concretions.
In order to understand this principle, you should know what I mean when I say that Class A
depends on Class B
.
Let’s go off-road for a bit to understand the above line.
Let’s say I have two classes ClassA
and ClassB
, the code is written as follows
class ClassA { | |
fun doSomething() { | |
println("Doing something") | |
} | |
} | |
class ClassB { | |
fun doIt() { | |
val classA = ClassA() // <- ClassB needs an object of ClassA in order to work properly | |
classA.doSomething() | |
} | |
} |
You can see in line 9 that an object of ClassA
is created and in line 10 the method doSomething()
is called. As ClassB
needs an object of ClassA
to function properly, we can say that ClassA
depends on ClassB
.
With the help of DIP, we will inverse this dependency.
The above diagram shows DIP in action as we have invested the dependency between ClassA
and ClassB
, the same can be seen in the above diagram.
Now let’s see the example to understand DIP.
An example where the classes depend on each other
Let’s say we have a NotificationService
which sends only one type of notification, i.e email notifications, as it is tightly coupled with the EmailNotification
class.
Create EmailNotification
class EmailNotification { | |
fun sendNotification(message: String) { | |
println("Sending email notification with message \"$message\"") | |
} | |
} |
Create NotificationService
class NotificationService { | |
fun sendNotification(message: String) { | |
val emailNotification = EmailNotification() // <- here is the dependency | |
emailNotification.sendNotification(message) | |
} | |
} |
Create NotificationDriver
fun main() { | |
val notificationService = NotificationService() | |
notificationService.sendNotification("Happy Coding") // Output: Sending email notification with message "Happy Coding" | |
} |
The problem is NotificationService
depends on EmailNotification
to send notifications. That is where the dependency comes in.
We have to remove the dependency in such a way that NotificationService
doesn’t depend on the type of notification and should be able to send different types of notifications.
Solution
The solution is pretty straightforward as we have already solved this problem when we looked at OCP.
In order for NotificationService
to be independent of the type of notification then it should depend on the abstract class or an interface rather than the concrete class, i.e EmailNotification
.
Create Notification
interface.
interface Notification { | |
fun sendNotification(message: String) | |
} |
Create the type of notifications — EmailNotification
and SmsNotification
class EmailNotification : Notification { | |
override fun sendNotification(message: String) { | |
println("Sending email notification with message \"$message\"") | |
} | |
} |
class SmsNotification : Notification { | |
override fun sendNotification(message: String) { | |
println("Sending sms notification with message \"$message\"") | |
} | |
} |
Create NotificationService
class NotificationService { | |
// this can be injected through constructor as well and it would be constructor injection | |
var notification: Notification? = null | |
fun sendNotification(message: String) { | |
notification?.sendNotification(message) | |
} | |
} |
Create NotificationDriver
fun main() { | |
val message = "Happy Coding" | |
val notificationService = NotificationService() | |
var notification: Notification | |
notification = EmailNotification() | |
notificationService.notification = notification | |
notificationService.sendNotification(message) | |
// Output: Sending email notification with message "Happy Coding" | |
notification = SmsNotification() | |
notificationService.notification = notification | |
notificationService.sendNotification(message) | |
// Output: Sending sms notification with message "Happy Coding" | |
} |
You can see that the NotificationService
now depends on the Notification
interface rather than the concretion, i.e Email Service, we can easily swap the implementation of the Notification
making the system flexible. Thus following the Dependency Inversion Principle.
Summary
All the SOLID principles can be defined in a single line as follows.
SRP — Each software module should have one, and only one, reason to change.
OCP — Software systems should be easy to change, they must be designed to allow the behavior of those systems to be changed by adding new code, rather than changing the existing code.
LSP — To build a software system from interchangeable parts, those parts must adhere to a contract that allows those parts to be substituted one for another.
ISP — Software designers should avoid depending on the things they don’t use.
DIP — The code that implements high-level policy should not depend on low-level details.
Source: Clean Architecture, Robert C. Martin
Conclusion
SOLID design principles are 5 core principles that every developer must know. These principles help us to write flexible, understandable, and maintainable code which is susceptible to changes.
How do you solve a similar problem in your project? Comment below or reach out to me on Twitter or LinkedIn.
Thank you very much for reading the article. Don’t forget to 👏 if you liked it.
References
This article was originally published on proandroiddev.com on December 05, 2022