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:

  1. The Logger class is responsible for logging messages.
  2. The Formatter class is responsible for formatting messages by adding a timestamp to the message.
  3. 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.