Design your classes with the best OOP practices 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