Ruby SOLID in practice with minimizing nesting rule
SOLID is an acronym for five design principles: Single Responsibility Principle, Open-Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle. Let's see how we can apply each of these principles in a Ruby application.
Single Responsibility Principle (SRP)
The SRP states that a class should have only one reason to change. This means that a class should have only one responsibility. Here's an example of how we can implement this principle:
class Logger
def initialize(log_file)
@log_file = log_file
end
def log(message, writer = Writer, formatter = Formatter)
formatted_message = formatter.format_message(message)
writer.write_to_file(formatted_message, @log_file)
end
end
class Formatter
def self.format_message(message)
"#{Time.now} - #{message}"
end
end
class Writer
def self.write_to_file(message, log_file)
File.open(log_file, 'a') { |file| file.puts(message) }
end
end
In this example, the Logger
class has a single responsibility - to log messages. It takes a message and writes it to a log file. We split the Logger
class into three separate classes to follow the SRP:
- The
Logger
class is responsible for logging messages. - The
Formatter
class is responsible for formatting messages by adding a timestamp to the message. - The
Writer
class is responsible for writing messages to a file.
Open-Closed Principle (OCP)
The OCP states that a class should be open to extension but closed to modification. This means that we should be able to extend the behaviour of a class without modifying its code. Here's an example of how we can implement this principle:
class PaymentMethod
def pay(_amount)
raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
end
end
class CreditCardPayment < PaymentMethod
def pay(amount)
# logic for credit card payment
end
end
class PayPalPayment < PaymentMethod
def pay(amount)
# logic for PayPal payment
end
end
In this example, the PaymentMethod
class is an abstract class that defines the pay
method as an abstract method. This means that any concrete subclass of PaymentMethod
must implement the pay
method.
We will then create two concrete subclasses of PaymentMethod
: CreditCardPayment
and PayPalPayment
. Each of these subclasses implements the pay
method with its own logic for processing payments.
Using the Open/Closed Principle, we can easily extend this code to add new payment methods without modifying the existing classes. We can create a new subclass of PaymentMethod
and implement the pay
method with the logic for the new payment method. This way, we can add new functionality without changing the existing code.
Liskov Substitution Principle (LSP)
The LSP says that a subclass should be substitutable for its superclass without affecting the correctness of the program. This means that we should be able to use a subclass wherever its superclass is expected.
Here's an example of how we can implement this principle:
class Car
def start_engine
puts "Car engine started"
end
end
class ElectricCar < Car
def start_engine
start_motor
end
def start_motor
"Electric car motor started"
end
end
def drive_car(car)
car.start_engine
end
# Create Car and ElectricCar objects
car = Car.new
electric_car = ElectricCar.new
# Call the drive_car function with both objects
drive_car(car) # "Car engine started"
drive_car(electric_car) # "Electric car motor started"
In this example, we have a Car
class and a ElectricCar
class, where ElectricCar
is a subclass of Car
. We override the start_engine
method in ElectricCar
to start the car's motor instead of the engine. This is in accordance with the Liskov Substitution Principle, which states that subtypes should be substitutable for their base types without affecting the correctness of the program.
We create a function drive_car
that takes an object of type Car
and calls the start_engine
method on it. We can pass both a Car
object and an ElectricCar
object to this function because ElectricCar
is a subtype of Car
.
When we call drive_car
with a Car
object, it outputs "Car engine started"
. When we call drive_car
with an ElectricCar
object, it outputs "Electric car motor started"
. This shows that we can substitute an ElectricCar
object for a Car
object without affecting the behaviour of the program.
Interface Segregation Principle (ISP)
The ISP says that a class should not be forced to depend on methods it does not use. This means that we should design interfaces that are specific to the needs of each class. Here's an example of how we can implement this principle:
# Define the interface for a printer
module Printer
def print(document)
raise NotImplementedError, "Subclass must implement this method"
end
end
# Define a class that only needs to print text
class TextPrinter
include Printer
def print(document)
puts document
end
end
# Define a class that only needs to print images
class ImagePrinter
include Printer
def print(document)
# print image logic here
end
end
In this example, we have a Printer
interface that defines a single method, print. The TextPrinter
and ImagePrinter
classes both include this interface, but each class implements only the method it needs. This follows the principle of interface separation, which states that no client should be forced to depend on methods it does not use.
The TextPrinter
class only needs to print text, so it implements the print
method to print the given document to the console. The ImagePrinter
class only needs to print images, so it implements the print
method with the logic to print images. Neither class is forced to implement methods it doesn't need, resulting in more focused and maintainable code.
If we were to violate the principle of interface separation and define a bloated Printer
interface with many methods, both the TextPrinter
and ImagePrinter
classes would have to implement all of them, even though they only use a small subset of them. This would result in unnecessary code complexity and maintenance overhead.
Dependency Inversion Principle (DIP)
The DIP says that high-level modules should not depend on low-level modules. Both should be dependent on abstractions. Abstractions should not be dependent on details. Details should be dependent on abstractions. Here's an example of how we can implement this principle:
class Order
def initialize(items)
@items = items
end
def total
@items.reduce(0) { |sum, item| sum + item.price }
end
end
class PaymentGateway
def process_payment(total)
# code to process the payment
end
end
class OrderProcessor
def initialize(order, payment_gateway)
@order = order
@payment_gateway = payment_gateway
end
def process_order
total = @order.total
@payment_gateway.process_payment(total)
@order.save
end
end
class OrderService
def initialize(order_processor)
@order_processor = order_processor
end
def process_order(order)
@order_processor.process_order(order)
end
end
In this example, the Order
class, the PaymentGateway
class, and the OrderProcessor
class are all low-level modules. The OrderService
class is a high-level module. The OrderService
class depends on an abstraction (the OrderProcessor
class), not on the details of how the order is processed. The OrderService
class calls the process_order
method of the OrderProcessor
object to process the order.
Minimize Nesting Rule
The "minimize nesting" rule is a programming best practice that advises against having too many levels of nested code in a program. This can make the code harder to read, harder to understand, and harder to debug and maintain.
Nesting can be minimized by using methods and avoiding excessive use of conditional statements and loops. Here's an example:
# Example of bad practice code with excessive nesting
def calculate_grade(student)
if student.nil?
puts "No student found."
else
if student.scores.nil? || student.scores.empty?
puts "No scores found for student."
else
total = 0
student.scores.each do |score|
total += score
end
average = total / student.scores.length
if average >= 90
puts "Student received an A."
elsif average >= 80
puts "Student received a B."
elsif average >= 70
puts "Student received a C."
elsif average >= 60
puts "Student received a D."
else
puts "Student received an F."
end
end
end
end
# Example of good practice code with reduced nesting
def calculate_grade(student)
return puts "No student found." if student.nil?
scores = student.scores
return puts "No scores found for student." if scores.nil? || scores.empty?
average = scores.sum / scores.length
case average
when 90..100
puts "Student received an A."
when 80..89
puts "Student received a B."
when 70..79
puts "Student received a C."
when 60..69
puts "Student received a D."
else
puts "Student received an F."
end
end
# Example of much better code with reducing nesting and using defs
def calculate_grade(student)
return puts "No student found." if student.nil?
scores = student.scores
return puts "No scores found for student." if scores.nil? || scores.empty?
average = calculate_average(scores)
grade = calculate_letter_grade(average)
puts "Student received a #{grade}."
end
def calculate_average(scores)
scores.sum / scores.length
end
def calculate_letter_grade(average)
case average
when 90..100
"A"
when 80..89
"B"
when 70..79
"C"
when 60..69
"D"
else
"F"
end
end
In the refactored code, we've removed the excessive nesting by using early returns for edge cases. We've also removed the duplication by using a case
statement to determine the grade based on the average score.
Negative opinions
Of course, there are a lot of negative opinions about being very strict using these rules:
- SOLID has been criticized for being overly complex and difficult to understand, especially for junior developers.
- Its rigidity can make it difficult to modify or refactor code as requirements change.
- Applying all of SOLID's principles can result in excessive code duplication, leading to higher maintenance costs and longer development times.
- Some argue that SOLID can distract from delivering business value because developers spend too much time complying with the principles instead of focusing on customer needs.
- Others argue that SOLID can lead to unnecessary abstraction and interface bloat, resulting in more complex code and reduced performance. In addition, SOLID can be difficult to apply in specific contexts, such as legacy code or small, simple projects (wasting time)
- Some critics believe that SOLID can lead to over-generalization, resulting in overly abstract and complicated code that is difficult to understand and maintain.
So try to be open-minded and use the right tool when you think it will help you!
Summary
In this article, we have seen how we can apply the SOLID principles in Ruby. By following these principles, we can write code that is easy to maintain, easy to extend, and easy to test.