DEV Community

Sathish
Sathish

Posted on • Edited on

The SOLID Object-Oriented Design Principles

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:

  1. The Single Responsibility Principle
  2. The Open-Closed Principle
  3. The Liskov Substitution Principle
  4. The Interface Segregation Principle
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)