SOLID Principles Explained
Tags: ruby, designpatterns, oop, solidprinciples
Published: 2023-04-04 12:56:57

The SOLID principles are a set of guidelines for writing clean and maintainable object-oriented code. The acronym SOLID stands for:

- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle

I’ll explain each of these principles with examples in Ruby:

## **Single Responsibility Principle (SRP)**

The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have only one responsibility.

Let’s say we have a User class that handles both authentication and profile management:

```
class User
  def authenticate(username, password)
    # some functionality
  end

  def update_profile(attributes)
    # some functionality
  end
end
```

This violates the SRP because the User class has two responsibilities: authentication and profile management. To follow the SRP, we should split this class into two:

```
class Authenticator
  def authenticate(username, password)
    # some functionality
  end
end

class UserProfileManager
  def update_profile(attributes)
    # some functionality
  end
end
```

so now each class has only one responsibility.

## **Open/Closed Principle**

The Open/Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means that you should be able to add new features to the software without changing the existing code.

Here’s an example of how to apply the OCP in Ruby:

```
# Bad example - not following OCP
class Car
  def initialize(make, model, year)
    @make = make
    @model = model
    @year = year
  end

  def start_engine
    puts "Starting engine for #{@make} #{@model}"
  end

  def shift_gears
    puts "Shifting gears for #{@make} #{@model}"
  end
end

class ElectricCar < Car
  def initialize(make, model, year)
    super(make, model, year)
  end

  def start_engine
    puts "Electric car does not have an engine."
  end
end
```

In this example, we have a Car class that has a `start_engine` and `shift_gears` method. We also have an `ElectricCar` class that inherits from `Car`. The problem is that the `start_engine` method is not applicable to an electric car, as they don't have an engine. So, we need to modify the `Car` class to handle this new type of car.

Let’s refactor this code to follow the OCP:

```
# Good example - following OCP
class Car
  def initialize(make, model, year)
    @make = make
    @model = model
    @year = year
  end

  def shift_gears
    puts "Shifting gears for #{@make} #{@model}"
  end
end

class ElectricCar < Car
  def initialize(make, model, year)
    super(make, model, year)
  end

  def start_engine
    puts "Electric car does not have an engine."
  end
end

class GasCar < Car
  def initialize(make, model, year)
    super(make, model, year)
  end

  def start_engine
    puts "Starting engine for #{@make} #{@model}"
  end
end
```

In this example, we removed the `start_engine` method from the `Car` class and created two new classes: `ElectricCar` and `GasCar`. Each class now handles its own implementation of the `start_engine` method, depending on the type of car. This way, we can add new types of cars without modifying the existing code.

In conclusion, the Open/Closed Principle encourages us to design software that is open to extension but closed to modification. By following this principle, we can create more flexible and maintainable software.

## **Liskov Substitution Principle (LSP)**

The Liskov Substitution Principle (LSP) is a principle in object-oriented programming that states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, a subclass should be able to substitute for its superclass without any unexpected behavior.

Let’s take an example of a class hierarchy in Ruby:

```
class Shape
  def area
  end
end

class Rectangle < Shape
  def area
    # calculates area of rectangle
  end
end

class Square < Shape
  def area
    # calculates area of square
  end
end
```

In this example, we have a superclass called `Shape` and two subclasses called `Rectangle` and `Square`. According to the LSP, we should be able to substitute an instance of `Rectangle` or `Square` for instance of `Shape` without any issues.

Let’s say we have a function that takes an instance of `Shape` and calculates its area:

```
def print_area(shape)
  puts "The area is: #{shape.area}"
end
```

We should be able to pass an instance of `Rectangle` or `Square` to this function without any issues:

```
rectangle = Rectangle.new
print_area(rectangle)

square = Square.new
print_area(square)
```

This code will run without any errors because `Rectangle` and `Square` both implement the `area` method, which is defined in the `Shape` superclass.

However, if we were to violate the LSP by creating a subclass that does not behave properly when substituted for its superclass, we could run into issues. For example:

```
class Triangle < Shape
  def area
    raise "Triangles don't have a defined area!"
  end
end

triangle = Triangle.new
print_area(triangle)
```

This code will raise an error because `Triangle` does not properly implement the `area` method. This violates the LSP because we cannot substitute an instance of `Triangle` for instance of `Shape` without unexpected behavior.

Therefore, it is important to follow the LSP when creating class hierarchies to ensure that the behavior of a subclass can be predicted and that it can be substituted for its superclass without causing issues.

## **Interface Segregation Principle(ISP)**

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. In other words, it’s better to have multiple smaller interfaces that are focused on a specific task rather than one large interface that tries to do everything.

Let’s consider an example in Ruby where we have a class `Printer` that can print documents. It has two methods, `print_pdf` and `print_word`.

```
class Printer
  def print_pdf(pdf_file)
    # Code to print PDF file
  end
  
  def print_word(word_file)
    # Code to print Word file
  end
end
```

This implementation violates the ISP because it forces clients to depend on methods they may not use. For instance, if a client only needs to print Word files, it still has to depend on the `print_pdf` method.

To apply ISP, we can refactor our code to create separate interfaces for each method. For example, we can create two interfaces `PdfPrinter` and `WordPrinter`.

```
module PdfPrinter
  def print_pdf(pdf_file)
    # Code to print PDF file
  end
end

module WordPrinter
  def print_word(word_file)
    # Code to print Word file
  end
end
```

Now, we can include only the required interface in our client classes instead of including the entire `Printer` class. For example, if we have a client class that only needs to print Word files, we can include the `WordPrinter` module:

```
class WordDocument
  include WordPrinter
end
```

Similarly, if we have another client class that only needs to print PDF files, we can include the `PdfPrinter` module:

```
class PdfDocument
  include PdfPrinter
end
```

This way, we have segregated our interfaces based on client needs.


## **Dependency Inversion Principle (DIP)**

The Dependency Inversion Principle(DIP) states:

> “High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.”

In other words, code should be designed so that high-level modules (like classes or modules) do not depend directly on lower-level modules or details of implementation, but rather on abstractions or interfaces that can be implemented by any number of lower-level modules. This allows for greater flexibility, extensibility, and maintainability of code.

Here’s an example of how you might implement the Dependency Inversion Principle in Ruby:

```
# High-level module (depends on abstraction)
class PaymentProcessor
  def initialize(payment_gateway)
    @payment_gateway = payment_gateway
  end

  def process_payment(amount)
    @payment_gateway.charge(amount)
  end
end

# Abstraction (does not depend on details)
class PaymentGateway
  def charge(amount)
    raise NotImplementedError
  end
end

# Low-level modules (implement abstraction)
class StripeGateway < PaymentGateway
  def charge(amount)
    # Code to charge using Stripe API
  end
end

class PaypalGateway < PaymentGateway
  def charge(amount)
    # Code to charge using PayPal API
  end
end
```

In this example, the `PaymentProcessor` class is a high-level module that needs to process payments. However, it doesn't depend on any specific payment gateway implementation - instead, it depends on an abstraction, the `PaymentGateway` class. This allows any number of lower-level modules (like `StripeGateway` or `PaypalGateway`) to implement the `PaymentGateway` interface, and be used by the `PaymentProcessor` without any modifications.

By following the Dependency Inversion Principle, this code is more flexible and easier to maintain. If a new payment gateway implementation needs to be added, for example, it can be implemented using the `PaymentGateway` interface and used by the `PaymentProcessor` without any changes to the `PaymentProcessor` itself.

In the end, I hope you found this article helpful in understanding the SOLID principles and how they can be applied in any programming language. Do you have any additional tips or examples for applying these principles in your own projects? I would love to hear your thoughts and feedback in the comments below.