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.