The SOLID design principles are five key principles that are used to design and structure a Class in an object-oriented design. They are a set of rules that needs to be followed to write effective code that’s easier to understand, collaborate, extend & maintain.
Why are they important?
Let’s answer this question with a use case and we’ll pull one principle from the five – which is ‘O - The Open-Closed Principle (Open to extension but closed to modification)’.
Aren’t we supposed to modify the class if there’s new functionality or modules to be added? Well, yes but actually no. It’s advised to not change/modify an already tested code that’s currently in production. This might lead to some side effects that would break the functionality of the entire class which will need further refactoring (Ugh! Work!). So, we need to structure it in such a way that it should always be open to extension and closed to modification.
That’s what I like more about conventions over configurations which Ruby on Rails offers. It says “Hey, let’s follow a similar convention and design patterns instead of bringing our own”. The one that looks good to me might not look good to others in a highly collaborative development environment we are currently having. Stop wasting time figuring things out and let’s start writing some code.
We’ll see more in detail about each of the principles and I will be using the Ruby programming language for examples. The concepts are applicable to any object-oriented programming language.
So, who formulated this? A Background
The theory of SOLID principles was introduced by Robert C. Martin (a.k.a Uncle Bob) in his 2000 paper Design Principles and Design Patterns & The SOLID acronym was introduced later by Michael Feathers.
The five principles are as follows:
- The Single Responsibility Principle
- The Open-Closed Principle
- The Liskov Substitution Principle
- The Interface Segregation Principle
- The Dependency Inversion Principle
Writing a clean understandable code is not only having multiple lines of comments that explain what (sometimes, the hell) is going on. The code you write should express your intent rather than depending on comments. In most cases, extensive comments are not required if your code is expressive enough.
The main goal of any design principles is - "To create understandable, readable, and testable code that many developers can collaboratively work on."
The Single Responsibility Principle
As the name implies, the single responsibility principle denotes that a class should have only one responsibility.
Consider a SaaS product that sends out weekly analytics to your users. There are two actions that need to be done to complete the process. One is to generate the report itself and the other will be to send the report. Let’s assume that we are emailing them.
Let’s see a scenario where we violate and then an example that follows the single responsibility principle.
# Violation of the Single Responsibility Principle in Ruby
class WeeklyAnalyticsMailer
def initialize(activities, user)
@activities = activities
@user = user
@report = ''
end
def generate_report!
# Generate Report
end
def send_report
Mail.deliver(
from: 'analytics@example.com',
to: @user.email,
subject: 'Yo! Your weekly analytics is here.',
body: @report
)
end
end
mailer = WeeklyAnalytics.new(user)
mailer.generate_report!
mailer.send_report
Even though sending an analytics email looks like a single action, it involves two different sub actions. Why is the above class a violation of the single responsibility principle? Because the class has two responsibilities, one is to generate the report and the other is to email them. Having the class name as WeeklyAnalyticsMailer, it shouldn’t do extra work than the intended one. This clearly violates the principle.
How to fix this? We will construct two different classes where one generates the report and the other emails to your users.
# Correct use of the Single Responsibility Principle in Ruby
class WeeklyAnalyticsMailer
def initialize(report, user)
@report = report
@user = user
end
def deliver
Mail.deliver(
from: 'analytics@example.com',
to: @user.email,
subject: 'Yo! Your weekly analytics is here.',
body: @report
)
end
end
class WeeklyAnalyticsGenerator
def initialize(activities)
@activities = activities
end
def generate
# Generate Report
end
end
report = WeeklyAnalyticsGenerator.new(activities).generate
WeeklyAnalyticsMailer.new(report, user).deliver
As planned, we have two classes that have their own dedicated responsibility and it doesn’t exceed one. If we want to extend the functionality of the mailer class (assume we use SendGrid to send out our email), we can simply make the necessary changes to the dedicated mailer class without touching the generator class.
The Open-Closed Principle
We looked briefly at the Open-Closed principle in the introduction. We’ll see more about it now.
The main goal of this principle is to create a flexible system architecture that is easier to extend the functionality of your application instead of changing or refactoring the existing source code that is in production.
“Objects or entities should be open for extension but closed for modification.”
Let’s assume an example where we again need to send the analytics to the user in
different formats and mediums.
# Violation of the Open-Closed Principle in Ruby
class Analytics
def initialize(user, activities, type, medium)
@user = user
@activities = activities
@type = type
@medium = medium
end
def send
deliver generate
end
private
def deliver(report)
case @type
when :email
# Send Report via Email
else
raise NotImplementedError
end
end
def generate
case @type
when :csv
# Generate CSV report
when :pdf
# Generate PDF report
else
raise NotImplementedError
end
end
end
report = Analytics.new(
user,
activities,
:csv,
:email
)
report.send
From the above example, we can send a CSV/PDF via email. If we want to add a new format, say raw and a new medium SMS, we need to modify the code which clearly violates our Open-Closed Principle. We’ll refactor the above code to follow the Open-Closed Principle.
# Correct use of the Open-Closed Principle in Ruby
class Analytics
def initialize(medium, type)
@medium = medium
@type = type
end
def send
@medium.deliver @type.generate
end
end
class EmailMedium
def initialize(user)
@user = user
# ... Setup Email medium
end
def deliver
# Deliver Email
end
end
class SmsMedium
def initialize(user)
@user = user
# ... Setup SMS medium
end
def deliver
# Deliver SMS
end
end
class PdfGenerator
def initialize(activities)
@activities = activities
end
def generate
# Generate PDF Report
end
end
class CsvGenerator
def initialize(activities)
@activities = activities
end
def generate
# Generate CSV Report
end
end
class RawTextGenerator
def initialize(activities)
@activities = activities
end
def generate
# Generate Raw Report
end
end
report = Analytics.new(
SmsMedium.new(user),
RawTextGenerator.new(activities)
)
report.send
We refactored the above class in such a way that the module can be easily extended without changing the existing code which now follows the Open-Close principle. Neat!
The Liskov Substitution Principle
According to Uncle Bob, “Subclasses should add to a base class’s behavior, not replace it.”.
A simple foo-bar example could be - all squares are rectangles and not vice versa. If we have a Rectangle class as our base class for our Square class. We then pass the length and width as the same values to compute the area of a square since all squares are rectangles. We will get the correct value but if we do the other way round, it will lead to an incorrect value.
To overcome this, we need to have a Shape class as our Base class, and the Rectangle and Square will extend from the Base class Shape which will satisfy the Liskov Substitution principle. Enough of the foo-bar examples. We’ll see a more realistic example.
In general, the Liskov Substitution Principle states that parent instances should be replaceable with one of their child instances without creating any unexpected or incorrect behaviour. Therefore, LSP ensures that abstractions are correct, and helps developers achieve more reusable code and better organize class hierarchies.
Let’s see an example that violates the principle. There’s a base class called UserInvoice which has a method to retrieve all invoices.
There’s a subclass AdminInvoice which inherits the base class that has the same method invoices
. The AdminInvoice will return a string when compared to the Base class method which returns an array of objects. This clearly violates the LSP since the subclass is not replaceable with the base class without any side effects as the subclass overwrites the behaviour of invoices method.
# Violation of the Liskov Substitution Principle in Ruby
class UserInvoice
def initialize(user)
@user = user
end
def invoices
@user.invoices
end
end
class AdminInvoice < UserInvoice
def invoices
invoices = super
string = ''
user_invoices.each do |invoice|
string += "Date: #{invoice.date} Amount: #{invoice.amount} Remarks: #{invoice.remarks}\n"
end
string
end
end
To fix this, we need to introduce a new format method in the sub class that handles the formatting. After this, the LSP can be satisfied since the sub class is interchangeable with the base class without any side effects.
# Correct use of the Liskov Substitution Principle in Ruby
class UserInvoice
def initialize(user)
@user = user
end
def invoices
@user.invoices
end
end
class AdminInvoice < UserInvoice
def invoices
super
end
def formatted_invoices
string = ''
invoices.each do |invoice|
string += "Date: #{invoice.date} Amount: #{invoice.amount} Remarks: #{invoice.remarks}\n"
end
string
end
end
The Interface Segregation Principle
The ISP says that “Clients shouldn’t depend on methods they don’t use. Several client-specific interfaces are better than one generalized interface.”.
This principle mainly focuses on segregating a fat base class to different classes. Let’s assume we have an ATM Machine that performs 4 actions - login, withdraw, balance, fill.
# Violation of the Interface Segregation Principle in Ruby
class ATMInterface
def login
end
def withdraw(amount)
# Cash withdraw logic
end
def balance
# Account balance logic
end
def fill(amount)
# Fill cash (Done by the ATM Custodian)
end
end
class User
def initialize
@atm_machine = ATMInterface.new
end
def transact
@atm_machine.login
@atm_machine.withdraw(500)
@atm_machine.balance
end
end
class Custodian
def initialize
@atm_machine = ATMInterface.new
end
def load
@atm_machine.login
@atm_machine.fill(5000)
end
end
We have two types of users User & Custodian where the user uses 3 actions (login, withdraw & balance) and the Custodian uses 2 (login & fill).
We have a single class called ATMInterface that does all the heavy lifting even though the client doesn’t need them (Ex: User doesn’t need to replenish while the Custodian doesn’t need to withdraw/check balance). This of course violates our ISP. Let’s segregate the above fat class into different subclasses.
# Correct use of the Interface Segregation Principle in Ruby
class ATMInterface
def login
end
end
class ATMUserInterface < ATMInterface
def withdraw(amount)
# Cash withdraw logic
end
def balance
# Account balance logic
end
end
class ATMCustodianInterface < ATMInterface
def replenish
# Fill cash (Done by the ATM Custodian)
end
end
class User
def initialize
@atm_machine = ATMUserInterface.new
end
def transact
@atm_machine.login
@atm_machine.withdraw(500)
@atm_machine.balance
end
end
class Custodian
def initialize
@atm_machine = ATMCustodianInterface.new
end
def load
@atm_machine.login
@atm_machine.replenish
end
end
The Dependency Inversion Principle
“High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.”
That’s too much detail in 4 sentences. Let’s see what it means. According to Uncle Bob, the DIP is the result of strictly following 2 other SOLID principles: Open-Closed & Liskov Substitution Principle. Hence, this will have clearly separate abstractions.
It should also be readable, extendable, and child classes should be easily replaceable by other instances of a base class without breaking the system.
# Violation of the Dependency Inversion Principle in Ruby
class Parser
def parse_xml(file)
XmlParser.new.parse(file)
end
def parse_csv(file)
CsvParser.new.parse(file)
end
end
class XmlParser
def parse(file)
# parse xml
end
end
class CsvParser
def parse(file)
# parse csv
end
end
The class Parser depends on classes XmlParser and CsvParser instead of abstractions, which indicates the violation of the DIP principle since the classes XmlParser and CsvParser may contain the logic that refers to other classes. Thus, we may impact all the related classes when modifying the class Parser.
#Correct use of the Dependency Inversion Principle in Ruby
class Parser
def initialize(parser: CsvParser.new)
@parser = parser
end
def parse(file)
@parser.parse(file)
end
end
class XmlParser
def parse(file)
# parse xml
end
end
class CsvParser
def parse(file)
# parse csv
end
end
Conclusion
Remember that there’s no single equation or rule. However, following a predefined set of rules correctly will yield a great design. Writing clean code comes with experience and the principles when used smartly will yield better results which are extendable, maintainable and will make everyone’s life easier.
Why did I write this post?
I’ve planned my career path into different segments. After my Computer Science degree, I set my course to explore, to play around with whatever tech that excites me. From JS to Rust, both Backend & Frontend. Hmm, I’ve explored and can easily pick up anything that excites me, understand and work with it. What’s next? Mastering.
You can follow me/my journey on Twitter/Github respectively. Bye!
Top comments (0)