Published on

SOLID principles

Ruby Software engineering

I’m sure you heard about SOLID principles at least one time. It’s also highly possible that you are sick of it. However, this topic is a pretty common thing during interviews, and besides that can help you design classes in a more readable, testable, and extendable way.

This article is a quick summary easy to memorize, so you will never wonder again what this SOLID term is all about.

Who created them and why

Robert C. Martin defined the SOLID principles to describe the basics of object-oriented programming. The term is composed of the first letters of these rules:

  • Single responsibility principle
  • Open/closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

Single responsibility principle

One class should always be responsible for doing only one thing. For example, if you want to design code that calls an API and parse the response, if you follow this principle, instead of having one class for both parsing and requesting:

class SomeApi
  def get_user_name(id)
    response = RestClient.get(request_url(id), headers)
    parsed_response = JSON.parse(response.body)
    parsed_response.dig('user', 'name')
  end

  private

  def request_url(id)
    "https://someapi.com/users/#{id}"
  end

  def headers
    {
      'header1' => 'value',
      'header2' => 'value'
    }
  end
end
api = SomeApi.new
user_name = api.get_user_name(1)

You would have a separate class for request and parsing:

class ApiRequest
  def get_user(id)
    RestClient.get(request_url(id), headers)
  end

  private

  def request_url(id)
    "https://someapi.com/users/#{id}"
  end

  def headers
    {
      'header1' => 'value',
      'header2' => 'value'
    }
  end
end

Parsing:

class ApiUserResponse
  def initialize(raw_response)
    @response = JSON.parse(raw_response.body)
  end

  def name
    @response.dig('user', 'name')
  end
end

Executing:

request = ApiRequest.new
response = request.get_user(1)
api_user = ApiUserResponse.new(response)
api_user.name

There is more code, but the code is more readable, testable, and extendable. If you want to change how the response is parsed, you shouldn’t need to change the class responsible for the request.

Open / closed principle

Classes that you build should be open for extension but closed for modification. In my personal opinion, this principle is similar to Dependency Inversion, but it’s more general.

You can follow this principle when using inheritance or design patterns like dependency injection or decorator. If you would follow the principle, instead of the following code:

class User
  def profile_name
    "#{first_name} #{last_name}".upcase
  end
end

You can build a decorator class that proves that the User class is open for extension but closed for modification:

class UserProfileDecorator
  def initialize(user)
    @user = user
  end

  def name
    "#{@user.first_name} #{@user.last_name}".upcase
  end
end

Liskov substitution principle

In simple words, if you have classes that inherit from the base class (adapters, for example), they should have the same method names, number of accepted arguments, and type of returned values. It’s then possible to replace (substitute) the base class with any of its children as they provide the same class structure.

Imagine that you write drivers for the database:

class MySQLDriver
  def connect; end

  def create_record; end

  def delete_record; end
end
class PostgreSQLDriver
  def connect_to_database; end

  def create; end

  def delete; end
end

Those drivers implement methods that work the same, but the naming is different. By following the Liskov substitution principle, we would end up with the following code:

class BaseDriver
  def connect
    raise 'NotImplemented'
  end

  def create_record
    raise 'NotImplemented'
  end

  def delete_record
    raise 'NotImplemented'
  end
end
class MySQLDriver < BaseDriver
  def connect; end

  def create_record; end

  def delete_record; end
end
class PostgreSQLDriver < BaseDriver
  def connect; end

  def create_record; end

  def delete_record; end
end

You can now substitute the BaseDriver class with any child class, and it would work the same way.

Interface segregation principle

If we map the principle definition to Ruby, the principle states that it’s better to have multiple classes (or methods) instead of one bigger class (or method).

Let’s assume that we have the User class and the export_attributes method:

class User
  def export(as: nil)
    return "#{attributes.keys.join(',')}\n#{attributes.values.join(',')}" if as == :csv

    attributes
  end
end

If you followed the interface segregation principle, you would end up with two separated methods:

class User
  def attributes
    # …
  end

  def to_csv
    "#{attributes.keys.join(',')}\n#{attributes.values.join(',')}"
  end
end

It’s much easier now to extend and test the class.

Dependency inversion principle

Let’s assume that you want to create a class that will generate user records in the database based on a given file (CSV or XLS). You already have separated parser classes:

class BaseParser
  def initialize(file_path)
    @file_path = file_path
  end

  def attributes
    raise 'NotImplemented'
  end
end
class CSVParser < BaseParser
  def attributes
    CSV.read(@file_path)
  end
end
class XLSParser < BaseParser
  def attributes
    SomeXLSGem.open(@file_path).sheet(0)
  end
end

If you would follow the dependency inversion principle, instead of having the dependency of parser classes in the importer class:

class UserImporter
  def initialize(file_path)
    @file_path = file_path
  end

  def import
    attributes.each do |user_attributes|
      User.create!(user_attributes)
    end
  end

  private

  def attributes
    if @file_path.end_with?('.csv')
      CSVParser.new(@file_path).attributes
    elsif @file_path.end_with?('.xls')
      XLSParser.new(@file_path).attributes
    end
  end
end

You would let the caller explicitly define the dependency and pass it to the importer class:

class UserImporter
  def self.import(parser)
    parser.attributes.each do |user_attributes|
      User.create!(user_attributes)
    end
  end
end

parser = XLSParser.new(file_path)
UserImporter.import(parser)

We removed the dependencies, and the class itself is much simpler and automatically follows the single responsibility principle.

Conclusion

If you need to quickly memorize what SOLID principles are all about, remember these points:

  • Robin C. Martin defined SOLID principles to describe object-oriented programming
  • Single responsibility principle - one class is responsible for only one thing
  • Open/closed principle - class should be open for extension and closed for modification
  • Liskov substitution principle - it should be possible to replace the parent class with its child class, and it would work the same
  • Interface segregation principle - it’s better to have multiple simple methods instead of one bigger
  • Dependency inversion principle - let the person who calls method define the dependency by passing them as arguments

I don't send any gifts

But you can subscribe to receive content related to Ruby

    Unsubscribe at any time.