SOLID is an acronym for five separate object-oriented design principles:
- The single-responsibility principle
- The open-closed principle
- The Liskov substitution principle
- The interface segregation principle
- The dependency inversion principle
Using these principles can result in well-structured classes. However, these principles should not be applied dogmatically. When designing a class, you should consider whether the benefits of each principle outweigh the costs. In this section, you'll learn about each of these principles, and the trade-offs inherent in each, to help you decide whether you would benefit from applying the principle.
The single-responsibility principle
The single-responsibility principle states that a class should have only one reason to be modified, which we'll refer to as a single purpose. Classes that adhere to this principle, built to serve a single purpose, are fine and easy to use. You've probably designed classes that serve a single purpose, and haven't had problems using them or working with them.
However, the single-responsibility principle is not generally used for justifying designing a class to serve a single purpose. It's almost always used to justify splitting a single class that serves multiple purposes into multiple classes that each serve a single purpose, or at least a smaller number of purposes. This application of the principle often results in increased overall system complexity, depending on how narrowly you define the class's purpose.
Consider Ruby's String class. Ruby's String class can serve multiple purposes. It can represent text in a specific encoding. It can also represent binary data. It can be used as a builder of text or data, or as their modifier. The flexibility of Ruby's String class is one of the reasons that Ruby is a joy to use. In Ruby, you don't need to deal with separate Text, Data, TextBuilder, DataBuilder, TextModifier, and DataModifier classes, as the String class handles all of these responsibilities. If you apply the single-responsibility principle, and change this code:
str = String.new
str << "test" << "ing...1...2"
name = ARGV[1].
to_s.
gsub('cool', 'amazing').
capitalize
str << ". Found: " << name
puts str
Into this code:
builder = TextBuilder.new
builder.append("test")
builder.append("ing...1...2")
modifier = TextModifier.new
name = modifier.gsub(ARGV[1].to_s, 'cool', 'amazing')
name = modifier.capitalize(name)
builder.append(". Found: ")
builder.append(name)
puts builder.as_string
Then you should consider whether the additional complexity you are adding is worth it. In many cases, libraries and applications will be more maintainable and easier to use if you employ a single class with multiple related purposes, compared to splitting the class into multiple classes, each with its own single purpose.
A good question to ask yourself when deciding whether to apply the single-responsibility principle to split up a class is, "Would I be able to use any of the new classes in additional places?" If the answer is yes, that is an indication that it may be a good idea to split up the class. However, if the answer is no, that is an indication that it may not be a good idea.
Another good question to ask yourself is, "Do I want to be able to easily replace certain parts of this class with alternative parts?" Let's say you are printing reports. Your code starts out with the ability to take a single type of input, and output a report in a single format. One design approach is to have a single Report class that holds all of the data for the report, and has all the methods used for formatting the report:
report = Report.new(data)
puts report.format
Alternatively, you could have a ReportContent class and ReportFormatter class, since the storage of data and the formatting of it into a report can be considered separate purposes:
report_content = ReportContent.new(data)
report_formatter = ReportFormatter.new
puts report_formatter.format(report_content)
Which of these approaches is better depends on whether and what type of future changes will be needed. If, in the future, you may need to support three different input types (such as flat file, SQL, and CSV) and three different report formats (such as docx, pdf, and xlsx), using separate classes can make that simpler. Instead of needing nine Report subclasses to handle all combinations of input type and report type, you can have three ReportContent subclasses and three ReportFormatter subclasses:
report_content = ReportContent.for_type(data_type).new(data)
report_formatter = ReportFormatter.for_type(report_type).new
puts report_formatter.format(report_content)
If you know in advance that you will need multiple input types and multiple report formats, separating the design into ReportContent and ReportFormatter classes upfront is a good idea. However, if you start out with only a single input format and single report format, the single Report class design is probably a better approach. You may never need to deal with multiple input types and multiple report formats, and burdening your code with excess complexity will make it harder to use. As a general principle, you should delay increasing complexity in your class designs until you actually need the complexity. It is easier to add necessary complexity than to remove unnecessary complexity, at least if you care about backward compatibility.
Applying the single-responsibility principle dogmatically leads to having many classes that each have minimal internal complexity. That allows for easy unit testing. However, it pushes complexity into the interactions between these many classes. In many cases, the additional complexity has a greater cost than the benefit provided. You should consider overall system complexity when deciding whether to apply the single-responsibility principle to split a class. If you are still unsure after consideration, you should probably not split the class.
The open-closed principle
The open-closed principle states that a class should be open for extension, but closed for modification. An extension in Ruby's case would be adding instance variables and methods, and a modification would be modifying or removing existing instance variables or methods. The open-closed principle was written to address issues with compiled software written in programming languages that are less flexible than Ruby. In Ruby, almost all classes are open for both extension and modification.
Ruby itself ignores the open-closed principle, and actively works to make sure classes aren't closed for modification. One of the most significant changes to Ruby's object model happened in Ruby 2.0, with the addition of origin classes. Origin classes are internal classes used to support the implementation of Module#prepend. Origin classes substantially increased the complexity of Ruby's object model, for the sole purpose of making modification easier, by providing a way for programmers to override any existing method and call super to get the default behavior.
Let's say you wanted to try enforcing the open-closed principle in Ruby. How would you go about it? Closing a class for extension and modification is done by calling ClassName.freeze, but closing a class for modification while leaving it open for extension is more challenging.
There are three typical ways to add methods to classes. Two of them involve defining the methods in a module, and then either including the module in the class, or prepending the module to the class. If you want to prevent modification, you could override include and prepend, and have them raise an exception if any of the modules passed have instance methods that overlap with the class's instance methods. You'd want to consider public, protected, and private methods when doing so.
To explore this, create an OpenClosed class, and add a singleton meths method to it, returning all instance methods in the given module. As instance_methods returns both public and protected methods, you need to also use private_instance_methods:
class OpenClosed
def self.meths(m) = m.instance_methods + m.private_instance_methods
Then you override the include singleton method to check for modification (method overlap):
def self.include(*mods)
my_meths = meths(self)
mods.each do
if meths(it).intersect?(my_meths)
raise "class closed for modification"
end
end
super
end
In this unique situation, you alias include as prepend. The main difference between these methods is the position of the passed modules in the class's method lookup hierarchy. If the methods being added don't overlap with existing methods, and you aren't passing a module that is one of the class's ancestors, then prepend is the same as include, so this doesn't break anything:
singleton_class.alias_method :prepend, :include
The third way to add methods to a class is to define the methods directly on the class. There isn't a hook called before adding a method, so unlike overriding include and prepend, you can't prevent the method from being added.
However, you can use the method_added hook, which is called after the method has been added, at which point the class or module has already been modified. Since the method_added hook is called directly after every method, you can undo the addition of the method and raise an exception. To be able to perform the undo, you must first create an alias to each method:
meths(self).each do
alias_name = :"__#{it}"
alias_method alias_name, it
end
You will use these alias methods later to restore the original implementation of a method when the method is overridden.
Then you can define a method_added hook. You only want this hook to run when external code adds a method to the class, and not when you are undoing the addition of the method, so this uses a trick you'll learn more about in Chapter 3, Proper Variable Usage, by having a local variable defined outside the method that is modified inside the method:
check_method = true
define_singleton_method(:method_added) do |method|
return unless check_method
If the method starts with the double underscore, someone could be trying to override an aliased method. Otherwise, they could be trying to override an actual method. If they are overriding the double underscore method, you need to replace it with the actual method. If they are overriding the actual method, you need to replace it with the double underscore method. So first you need to determine which replacement method to look for:
restore_with = if method.start_with?('__')
method[2..-1]
else
:"__#{method}"
end
Then you check whether that replacement method exists. If so, then the method added is a modification, so you replace the method just defined with the method holding the original implementation, and then you raise an exception to alert the user that the class is closed for modification. If the replacement method does not exist, the method added is an extension, so you allow it:
if private_method_defined?(restore_with) ||
method_defined?(restore_with)
check_method = false
alias_method method, restore_with
check_method = true
raise "class closed for modification"
end
end
end
This approach handles most cases well, but there are still many ways around it. A user could exploit a race condition in the implementation by trying to override methods in concurrently executing threads, either by exploiting the time slices where check_method is set to false, or by having one thread override the regular method and a concurrent thread override the double underscore method at the same time. The simplest workaround for the user who wants to modify the class is to remove the method performing the check:
OpenClosed.singleton_class.remove_method(:method_added)
Long story short, because a user can always find a way to work around the restrictions, it is pointless to try to get Ruby classes to be open for extension and closed for modification. Your choices are to leave the class open for both modification and extension, or to freeze the class and close it for both modification and extension. Note that in order to truly close a class for both modification and extension, you not only need to freeze the class itself, but all of the class's ancestors as well, up to BasicObject and any modules included in BasicObject.
The Liskov substitution principle
The Liskov substitution principle states that any place where you can use an object of type T, you can also use an object of a subtype of T. In Ruby, this is generally interpreted as any place in your code where you are using an instance of a class, you can also use an instance of a subclass of that class without anything breaking. As you'll learn in Chapter 15, Static or Duck, there are Ruby core classes that do not follow this principle.
In general, this is a good principle to follow. When you subclass an existing class, if you override a method of the class, you should attempt to ensure that it accepts the same argument types and returns the same argument type.
Assume you have a class named Max that stores a maximum value, and has an over? method that returns whether the maximum value is greater than a given value:
class Max
def initialize(max) = @max = max
def over?(n) = @max > n
end
If you create a subclass of Max, and override over? to require an additional argument, you violate the Liskov substitution principle:
class MaxBy < Max
def over?(n, by) = @max > n + by
end
This is because code that accepts an instance of Max, and calls over? on it with a single argument, will break if passed an instance of MaxBy. To be compliant with the Liskov substitution principle, the additional parameter must be optional:
class MaxBy < Max
def over?(n, by: 0) = @max > n + by
end
With this approach, passing an instance of MaxBy will work, because passing a single argument to MaxBy#over? results in the same behavior as Max#over?, assuming that you initialized the MaxBy instance with a numeric value.
While the Liskov substitution principle is generally a good practice, you should not be dogmatic about applying it. In a strict sense, all subtypes that have different behavior than their supertypes or produce different results could be said to violate Liskov substitutability, even if they expose the same API. And what would be the point of having a subclass with exactly the same behavior as the parent class?
Fundamentally, attempting to adhere to the Liskov substitution principle means limiting what changes you are willing to allow in a subclass. Such limiting may not make sense in all cases. There are cases where you may want a subclass with different behavior than the superclass. Does that mean that passing an instance of that subclass to code that expects an instance of a superclass may break? Yes, but that is not necessarily a problem. As long as you don't pass subclass instances in that case, and you can still happily use subclass instances in other cases. If you think a MaxBy#over? method that requires two arguments is less error prone than one that uses an optional argument, you are probably better off requiring two arguments. You only need to ensure that you don't pass MaxBy instances to code expecting Max instances and calling the over? method on them.
As a general rule, Ruby respects the programmer. Ruby doesn't prevent behavior simply because there are ways to misuse it. Most Ruby methods are not concerned about specific classes of object; they are concerned about whether the objects respond to the expected methods appropriately. In practice, Liskov substitutability doesn't matter much in Ruby.
There is one Ruby method that will almost always break Liskov substitutability, and that you should generally avoid, and that is instance_of?. When you use instance_of:
if obj.instance_of?(Max)
else
end
All subclasses of that class will break Liskov substitutability. Instead of taking the if branch, they will take the else branch. The same is true when using an equality comparison on the result of the class method:
if obj.class == Max
else
end
These approaches should generally use kind_of? instead, so that subclasses are treated the same way:
if obj.kind_of?(Max)
else
end
The interface segregation principle
The interface segregation principle states that clients should not be forced to depend on methods they do not use. This doesn't strictly apply to Ruby, since Ruby will only call methods that are used, but a looser interpretation is that this describes how large classes should be in terms of methods.
Classes with a large number of methods, where the programmer is only using a few of the methods, can be more difficult to understand. If 80% of your users use the same 20% of methods of a class, it may make sense to move many of the methods to a separate module, assuming that backward compatibility is not an concern. The 20% of users who need the methods can include the module in the class, while the other 80% can benefit from the smaller class.
In the real world, it's less likely that you'll have 80% of users using the same 20% of the methods. More likely, you'll have 80% of users using 20% of the methods, but which 20% are used varies from one user to the next.
For the most part, Ruby's core classes do not follow the interface segregation principle. Classes such as String, Array, and Hash have large numbers of methods. Some Rubyists would probably argue that they all have too many methods, but there would likely be differences in which methods they would vote to remove from each.
As a general principle, splitting up a large module solely because it is large is not beneficial. Having three modules with 10 methods each is not necessarily better than having one module with 30 methods. Having multiple modules, where each programmer only uses the modules they need, can reduce the runtime overhead, but it can also increase the cognitive overhead for the programmer.
You should avoid splitting a large interface into smaller interfaces if the reason for doing so is that the interface is large. It may make sense to split an interface if you can clearly separate useful methods in the interface into separate categories, where some categories will be needed in some applications but not in others. In Chapter 8, Designing for Extensibility, you'll learn more about using this kind of interface segregation via plugin systems.
The dependency inversion principle
The dependency inversion principle states that high-level modules should not depend on low-level modules, and both high-level and low-level modules should depend on abstractions. It also states that abstractions should not depend on concrete implementations, but that concrete implementations should depend on abstractions.
More complex code is harder to understand than simpler code. Whether an abstraction makes code more complex by adding unneeded flexibility, or makes code simpler by unifying concrete cases, depends on the abstraction. Abstractions are not intrinsically useful. Abstractions are only useful to the extent that they can make other code simpler.
One concrete implementation of the dependency inversion principle is dependency injection. With dependency injection, everything an object depends on should be passed into the object, to allow for maximum flexibility. Ruby doesn't require dependency injection as much as other programming languages, due to its flexibility of allowing singleton methods on almost all objects. However, dependency injection can still be used in Ruby, and there are Ruby libraries dedicated to it.
Assume you create a CurrentDay class to represent the current day. You add a work_hours method that returns the work hours for the current day, and a workday? method that returns whether the current day is a workday. You have an existing MonthlySchedule class that knows the work schedule for a given month, which you initialize with the year and month:
class CurrentDay
def initialize
@date = Date.today
@schedule = MonthlySchedule.new(@date.year, @date.month)
end
def work_hours = @schedule.work_hours_for(@date)
def workday? = !@schedule.holidays.include?(@date)
end
One issue with this approach is that testing the CurrentDay class becomes difficult. How can you test the workday? method? If you are testing on a workday, it will always be true. If you are testing on a non-workday, it will always be false.
One approach to handling this in the tests, without changing the implementation, is to override Date.today:
before do
Date.singleton_class.class_eval do
alias_method :_today, :today
define_method(:today){Date.new(2026, 7, 29)}
end
end
after do
Date.singleton_class.class_eval do
alias_method :today, :_today
remove_method :_today
end
end
One issue with this approach is that it prevents you from using multithreaded tests. However, the main issue is that this approach results in much more complex test code. In general, you wouldn't write this type of code directly, instead relying on your testing library's mocking/stubbing features. However, be aware that those mocking/stubbing features use this type of approach internally.
In some cases, you could use instance_variable_set to override the instance variables in the object when testing. Unfortunately, that doesn't work in this case, because the @date instance variable is used to set the @schedule instance variable inside initialize. It's also a bad idea in general, as it makes the tests brittle, since they will break if you change the implementation, even if there are no bugs in the new implementation.
For this type of situation, it makes sense to accept the date to use as an optional argument. It's probably best to use a keyword parameter for this, as that makes it easier to add a required positional parameter later:
class CurrentDay
def initialize(date: Date.today)
@date = date
@schedule = MonthlySchedule.new(date.year, date.month)
end
end
This begs the question of whether you should also allow the @schedule instance variable to be overridden via an argument:
class CurrentDay
def initialize(date: Date.today,
schedule: MonthlySchedule.new(date.year, date.month))
@date = date
@schedule = schedule
end
end
This is probably a bad idea unless you really need it for some other reason, as it can result in a caller passing a schedule for a different month than the month of date. An alternative that avoids that issue is to use an optional schedule_class parameter:
class CurrentDay
def initialize(date: Date.today, schedule_class: MonthlySchedule)
@date = date
@schedule = schedule_class.new(date.year, date.month)
end
end
However, you probably should not add such a parameter unless you need the ability to mock the work_hours_for or holidays methods in the schedule.
In this section, you learned how the SOLID design principles can be applied to Ruby programs. In the next section, you'll learn about trade-offs involved when choosing between a smaller number of larger classes and a larger number of smaller classes.