Main page background image

Ruby SOLID design principles


Ihor T.
RoR Developer

Why do we need design principles?

Before we start, let’s define good code and how we can identify it.

First, there is a short list of the qualities that a good code should have.

  • Readable
  • Maintainable
  • Scalable
  • Flexible
  • Loosely coupled
  • Testable

Initially, everything looks pretty simple and understandable when your project consists of only a few lines of code. Then, changes that need to be made to meet new requirements are easy to implement and test. But as time goes by, and we make more changes, the further and further our project is from good code.

I’m assuming you’re familiar with when a simple change that should have taken an hour takes a week or when onboarding new team members take a very long time before they start producing any results. All because your project became too challenging to understand and maintain. It gets too expensive.

Therefore, for our project to correspond to the qualities of good code for as long as possible, it is worth taking a closer look at design principles that can help us.

SOLID principles

  • Single Responsibility
  • Open-closed
  • Liskov Substitution
  • Interface Segregation
  • Dependency inversion

Single Responsibility Principle

Single Responsibility Principle (SRP) - a class, module, or method should have one and only one responsibility and only one reason to change.

You may ask how to define this single responsibility for a class or method. Let’s look at an example:

class StockMarket
  def initialize
    # loads the data from yml file.
    @stocks = YAML.safe_load('/path/to/database.yml')
  end

  def search_stocks(opts)
    # the method is responsible for finding stocks
  end

  def add_stock(stock)
    # the method is responsible for adding new stocks to the database
  end

  def print_results(stocks)
    # the method is responsible for printing the search results to the console
  end
end

market = StockMarket.new
stocks = market.search_stocks({})
market.print_results(stocks)

Now it’s time to explore the StockMarket class. What responsibilities does it take on?

  • looking for stocks
  • And it adds stocks to the database
  • And it prints the results

How many ANDs do we have? Every time you try to describe your class or method, you see that the words AND/OR mean that your class or method is not responsible for just one thing. AND - is the code smell we want to avoid.

To comply with the Single Responsibility Principle, I want to change our program slightly.

class Stocks
  def initialize
    @stocks = YAML.safe_load('/path/to/database.yml')
  end

  # return [Array<Hash>]
  def search(opts); end

  def add(stock); end
end

class PrintResults
  def initialize(results)
    @results = results
  end

  def print
    @results.each do |result|
      p result
    end
  end
end

stocks = Stocks.new.search({})
PrintResults.new(stocks).print

Now we have two classes, Stocks and PrintResults.

But wait for a second! You can tell the Stocks class does not match the SRP because it searches for stocks AND adds stocks to the database.

The Single Responsibility Principle does not mean one method or type of action per class or module but a single responsibility in the scope of a matter.

Persistence operations are part of the same matter. Thus, putting them all in the same class does not violate the principle.

Open-Closed principle

Open-Closed Principle - a class, module, or method should be open for extension but closed for modification. In other words, we should be able to change the program’s behavior without modifying classes or modules.

Let’s say there’s a new requirement, and now we want to use both the YAML file as our main database and JSON.

class YAMLDatabase
  def load; end # loads the data from yml file.
end

class JSONDatabase
  def load; end # loads the data from json file.
end

class Stocks
  def initialize(database)
    @stocks = database.load
  end
  ...
end

database = JSONDatabase.new
stocks = Stocks.new(database)
stocks.search({})

From now on, we can specify which data source to use. And if someday we want to add another data source, we don’t need to change anything in the application. Instead, we need to add another class to load data from the new source.

Liskov Substitution principle

Liskov Substitution principle - specifies that objects of its subclasses should replace objects of a superclass without disrupting the application.

It requires that the objects of your subclasses behave just like the objects of your superclass.

For example, let’s say Stocks is our parent class, and we add a child class, SPStocks (S&P 500 stocks). So we want to separate “S&P 500” stocks from all we have in the database.

class Stocks
  ...
  # return [Array<Hash>]
  def search(opts)
    # returns an array of stocks
    [{ ticker: 'AAPL' }, ...]
  end
  ...
end

class SPStocks < Stocks
  ...
  # return [Hash]
  def search(opts)
    # we overide original method and now we return a Hash of stocks
    { 'AAPL' => {}, ... }
  end
  ...
end

stocks = Stocks.new(database).search({}) # => [...]
PrintResults.new(stocks).print # => prints the result of the search

sp_stocks = SPStocks.new(database).search({}) # => {...}
PrintResults.new(sp_stocks).print # => fails because the PrintResults class expects the results to be an array

Now it’s apparent that we can not easily replace the instance of the Stocks class with the SPStocks class because the Stocks#search returns an array of stocks, but the SPStocks#search returns a hash.

To comply with the Liskov Substitution principle, we want the SPStocks#search to return the same datatype as its parent class. The same applies to all methods that the SPStocks class may override or change from the ‘Stocks’ class.

Interface Segregation principle

Interface Segregation Principle (ISP)- code should not be forced to depend on methods it doesn’t use.

I will explain it with the following example.

Imagine that new requirements have arrived, and we want to highlight every even record with a different color when we call the PrintResults#print method. So, we change the PrintResults class:

class PrintResults
  ...
  def print(highlight_even); end
  ...
end

# part of the application that wants to highlight every even record
PrintResults.new(stocks).print(true)

# Another part of the application that also uses the print method.
# Even though this part of the code doesn't need to allocate even records, we still need to pass a boolean value to the print method, otherwise we'll get an error.
PrintResults.new(stocks).print(false)

That’s how we can violate the Interface Segregation principle!

To avoid this, we can add a new method to the PrintResults class, something like PrintResults#highlight_print. In this case, our code does not depend on changes in the interface.

class PrintResults
  ...
  def print; end # we don't touch the original print, so any parts of the code that already use it don't need to be changed

  def highlight_print; end
  ...
end

Dependency Inversion principle

Dependency Inversion principle (DIP):

  • A high-level module should not depend on a low-level module
  • both should depend on the abstraction

You can find these definitions in any other article. Let’s better see an example.

When loading a database, we may need specific configurations such as the file path, or if we are getting the file via HTTP, we may need to provide the URL, passwords, etc. Instead of hardcoding these configurations in a class, we need to pass them down from the caller.

class YAMLDatabase
  def initialize(config)
    @config = config
  end

  def load; end # loads the data from yml file.
end

class JSONDatabase
  def initialize(config)
    @config = config
  end

  def load; end # loads the data from json file.
end

config = { url: '...', password: '...' } # it can be a standalone class
database = YAMLDatabase.new(config)

Now the caller has control over the dependency and can easily change the configuration when needed.

In turn, YAMLDatabase and JSONDatabase now know nothing about the configuration object and become less dependent.

Summary

In conclusion, I would like to say that the SOLID principles are not a rescue from all problems, and it is worth using them wisely. However, it may not be worth the trouble for projects that last a couple of months, as the time spent implementing a good design may never pay off.