SOLID is an acronym created by Bob Martin and Michael Feathers that refers to five fundamental principles that help engineers write maintainable code. We like to think of these principles as the foundational elements we use when evaluating the health of our codebase and architectural approach. The principles that make up the acronym are as follows:
- S - Single Responsibility Principle
- O - Open/Closed Principle
- L - Liskov Substitution Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
Let’s take a closer look at each of these principles with some examples.
Single Responsibility Principle
The Single Responsibility Principle is the most abstract of the bunch. It helps keep classes and methods small and maintainable. In addition to keeping classes small and focused it also makes them easier to understand.
While we all agree that focusing on a single responsibility is important, it’s difficult to determine what a class’s responsibility is. Generally, it is said that anything that gives a class a reason to change can be viewed as a responsibility. By change I am talking about structural changes to the class itself (as in modifying the code in the class’s file, not the object’s in-memory state). Let’s look at an example of some code that isn’t following the principle:
class DealProcessor
def initialize(deals)
@deals = deals
end
def process
@deals.each do |deal|
Commission.create(deal: deal, amount: calculate_commission)
mark_deal_processed
end
end
private
def mark_deal_processed
@deal.processed = true
@deal.save!
end
def calculate_commission
@deal.dollar_amount * 0.05
end
end
In the above class we have a single command interface that processes commission
payments for deals. At first glance the class seems simple enough, but let’s
look at reasons we might want to change this class. Any change in how we
calculate commissions would require a change to this class. We could introduce
new commission rules or strategies that would cause our calculate_commission
method to change. For instance, we might want to vary the percentage based on
deal amount. Any change in the steps required to mark a deal as processed in the
mark_deal_processed
method would result in a change in the file as well. An
example of this might be adding support for sending an email summary of a
specific person’s commissions after marking a deal processed. The fact that we
can identify multiple reasons to change signals a violation of the Single
Responsibility Principle.
We can do a quick refactor and get our code in compliance with the Single Responsibility Principle. Let’s take a look:
class DealProcessor
def initialize(deals)
@deals = deals
end
def process
@deals.each do |deal|
mark_deal_processed
CommissionCalculator.new.create_commission(deal)
end
end
private
def mark_deal_processed
@deal.processed = true
@deal.save!
end
end
class CommissionCalculator
def create_commission(deal)
Commission.create(deal: deal, amount: deal.dollar_amount * 0.05)
end
end
We now have two smaller classes that handle the two specific tasks. We have our processor that is responsible for processing and our calculator that computes the amount and creates any data associated with our new commission.
Open/Closed Principle
The Open/Closed Principle states that classes or methods should be open for extension, but closed for modification. This tells us we should strive for modular designs that make it possible for us to change the behavior of the system without making modifications to the classes themselves. This is generally achieved through the use of patterns such as the strategy pattern. Let’s look at an example of some code that is violating the Open/Closed Principle:
class UsageFileParser
def initialize(client, usage_file)
@client = client
@usage_file = usage_file
end
def parse
case @client.usage_file_format
when :xml
parse_xml
when :csv
parse_csv
end
@client.last_parse = Time.now
@client.save!
end
private
def parse_xml
# parse xml
end
def parse_csv
# parse csv
end
end
In the above example we can see that we’ll have to modify our file parser anytime we add a client that reports usage information to us in a different file format. This violates the Open/Closed Principle. Let’s take a look at how we might modify this code to make it open to extension:
class UsageFileParser
def initialize(client, parser)
@client = client
@parser = parser
end
def parse(usage_file)
parser.parse(usage_file)
@client.last_parse = Time.now
@client.save!
end
end
class XmlParser
def parse(usage_file)
# parse xml
end
end
class CsvParser
def parse(usage_file)
# parse csv
end
end
With this refactor we’ve made it possible to add new parsers without changing
any code. Any additional behavior will only require the addition of a new
handler. This makes our UsageFileParser
reusable and in many cases will keep
us in compliance with the Single Responsibility Principle as well by encouraging
us to create smaller more focused classes.
Liskov Substitution Principle
Liskov’s principle tends to be the most difficult to understand. The principle states that you should be able to replace any instances of a parent class with an instance of one of its children without creating any unexpected or incorrect behaviors.
Let’s look at a example of a Liskov violation. We’ll start with the classic example of the relationship between a rectangle and a square. Let’s take a look:
class Rectangle
def set_height(height)
@height = height
end
def set_width(width)
@width = width
end
end
class Square < Rectangle
def set_height(height)
super(height)
@width = height
end
def set_width(width)
super(width)
@height = width
end
end
For our Square
class to make sense we need to modify both height and width
when we call either set_height
or set_width
. This is the classic example of
a Liskov violation. The modification of the other instance method is an
unexpected consequence. If we were taking advantage of polymorphism and
iterating over a collection of Rectangle
objects one of which happened to be a
Square
, calling either method will result in a surprise. An engineer writing
code with an instance of the Rectangle
class in mind would never expect
calling set_height
to modify the width of the object.
Another common instance of a Liskov violation is raising an exception for an overridden method in a child class. It’s also not uncommon to see methods overridden with modified method signatures causing branching on type in classes that depend on objects of the parent’s type. All of these either lead to unstable code or unnecessary and ugly branching.
Interface Segregation Principle
This principle is less relevant in dynamic languages. Since duck typing languages don’t require that types be specified in our code this principle can’t be violated.
That doesn’t mean we shouldn’t take a look at a potential violation in case we’re working with another language. The principle states that a client should not be forced to depend on methods that it does not use.
Let’s take a closer look at what this means in Swift. In Swift, we can use protocols to define types that will require concrete classes to conform to the structures they outline. This makes it possible to create classes and methods that require only the minimum API.
In this example we’ll create two classes Test
and User
. We’ll also reference
a Question
class that I will omit since it will not be necessary for the sake
of this example. Our user will take tests, and tests can be scored and taken.
Let’s take a look:
class Test {
let questions: [Question]
init(testQuestions: [Question]) {
questions = testQuestions
}
func answerQuestion(questionNumber: Int, choice: Int) {
questions[questionNumber].answer(choice)
}
func gradeQuestion(questionNumber: Int, correct: Bool) {
question[questionNumber].grade(correct)
}
}
class User {
func takeTest(test: Test) {
for question in test.questions {
test.answerQuestion(question.number, arc4random(4))
}
}
}
Our User
would not get a very good grade because they’re randomly choosing
test answers, but we also have a violation of the Interface Segregation
Principle. Our takeTest
requires we provide an argument of type Test
. The
Test
type has two methods. takeTest
depends on answerQuestion
but does not
care about gradeQuestion
. Let’s take advantage of Swift’s protocols to fix
this and get back on the right side of our Interface Segregation Principle.
protocol QuestionAnswering {
var questions: [Question] { get }
func answerQuestion(questionNumber: Int, choice: Int)
}
class Test: QuestionAnswering {
let questions: [Question]
init(testQuestions: [Question]) {
self.questions = testQuestions
}
func answerQuestion(questionNumber: Int, choice: Int) {
questions[questionNumber].answer(choice)
}
func gradeQuestion(questionNumber: Int, correct: Bool) {
question[questionNumber].grade(correct)
}
}
class User {
func takeTest(test: QuestionAnswering) {
for question in test.questions {
test.answerQuestion(question.number, arc4random(4))
}
}
}
Our takeTest
method now requires a QuestionAnswering
type. This is an
improvement because we can now use this same logic for any type that conforms to
this protocol. Perhaps down the road we would like to add a Quiz
type, or even
different types of tests. With our new implementation we could easily
reuse this code.
Dependency Inversion Principle
The Dependency Inversion Principle has to do with high-level (think business logic) objects not depending on low-level (think database querying and IO) implementation details. This can be achieved with duck typing and the Dependency Inversion Principle. Often this pattern is used to achieve the Open/Closed Principle that we discussed above. In fact, we can even reuse that same example as a demonstration of this principle. Let’s take a look:
class UsageFileParser
def initialize(client, parser)
@client = client
@parser = parser
end
def parse(usage_file)
parser.parse(usage_file)
@client.last_parse = Time.now
@client.save!
end
end
class XmlParser
def parse(usage_file)
# parse xml
end
end
class CsvParser
def parse(usage_file)
# parse csv
end
end
As you can see, our high-level object, the file parser, does not depend directly on an implementation of a lower-level object, XML and CSV parsers. The only thing that is required for an object to be used by our high-level class is that it responds to the parse message. This decouples our high-level functionality from low-level implementation details and allows us to easily modify what those low-level implementation details are. Having to write a separate usage file parser per file type would require lots of unnecessary duplication.
What’s next
If you found this useful, learn more by watching the SOLID videos on The Weekly Iteration: