SOLID Design Patterns in Rails

Mastering SOLID Principles and Design Patterns in Rails

Tushar Adhao
8 min readSep 3, 2024

In the world of software engineering, many blogs talk about SOLID principles and design patterns in theory. This blog stands out by not only explaining these concepts but also showing how to use them in real Rails projects, using Design patterns like Builder, Adapter, Interactor, and Observer to make your code better.

Feed your code with SOLID principles using a sandwich so good even ducks love it!

We’ll also look at how concepts like cohesion and coupling work with these principles, providing clear examples and practical advice. By the end, you’ll have a better understanding of how to use these techniques to improve your Rails applications.

Cohesion and Coupling: The Dynamic Duo

Cohesion: Imagine a toolbox where each tool has a specific job. High cohesion means each tool (or class) has a single, clear purpose. For example, a hammer is for hammering nails, not for cutting wood. When a class is highly cohesive, it does one thing well, making it easier to understand and maintain.

Coupling: Now, think about how these tools interact. Low coupling means tools (or classes) work independently of each other. For instance, a hammer doesn’t need to know how a saw works. Low coupling means changes to one tool (class) won’t mess up others, making your code more flexible and less prone to errors.

Effective use of SOLID principles and design patterns helps achieve high cohesion and low coupling, leading to more robust and adaptable code.

Now that we’ve covered the essentials, let’s dive into the main topic.

Note: For the best understanding and to get the most out of this blog, it’s strongly recommended to first review the Design Patterns in Rails guide before diving into the content here.

Single Responsibility Principle (SRP)

Definition: A class should have only one responsibility or reason to change.

SRP means each class should focus on a single task. This makes it easier to manage because if something needs to change, you only have to update one class. High cohesion is achieved by keeping classes focused, and low coupling is maintained by separating responsibilities.

Bad Example:

class User < ApplicationRecord
after_create :send_welcome_email

def send_welcome_email
# code to send email
end
end

In this example, the User model is handling both user data management and sending emails, violating SRP.

Improved Example with Observer Pattern:
The Observer Pattern lets one object notify others automatically about changes, without needing to know who the observers are.

Let’s understand it with a news channel example!

The Observer Pattern is like a news channel that broadcasts updates. When a new story airs, the news channel informs various viewers (such as a TV app, a mobile alert, and an email newsletter) about the latest news. The news channel doesn’t need to know how each viewer will present the information; it just sends out the updates. This keeps the news channel simple and focused on providing news, while each viewer handles how they display it.

class User < ApplicationRecord
after_create :notify_observers

def add_observer(observer)
@observers ||= []
@observers << observer
end

private

def notify_observers
@observers.each { |observer| observer.update(self) }
end
end

class WelcomeEmailObserver
def update(user)
# code to send welcome email
end
end

# Usage
user = User.new(...)
user.add_observer(WelcomeEmailObserver.new)
user.save

Now, User just handles user data, and WelcomeEmailObserver manages email sending. This keeps the responsibilities separate and focused.

Open/Closed Principle (OCP)

Definition: Code should be open for extension but closed for modification.

The Open/Closed Principle suggests that you should be able to extend the behavior of a class without altering its existing code. This approach enhances the stability of the codebase while allowing new features to be added.

Bad Example:

class Report
def generate(type)
case type
when :pdf
generate_pdf
when :csv
generate_csv
end
end
end

Here, the Report class requires modification to support new report types.

Improved Example with Builder Pattern:
The Builder Pattern simplifies the creation of complex objects by separating the construction process from the final object representation.

Let’s explore the Builder Pattern using a tasty pizza example!

Think of a customizable pizza. You can create different types of pizzas (like a pepperoni pizza, a veggie pizza, or a paneer pizza) using the same base ingredients (dough, sauce, cheese). The Builder Pattern works similarly by letting you create various types of objects (like different pizza recipes) without altering the core structure. You use a common process (like choosing toppings) to build different versions of the final product.

class Report
def initialize(builder)
@builder = builder
end

def generate
@builder.build
end
end

class PdfBuilder
def build
# code to build PDF report
end
end

class CsvBuilder
def build
# code to build CSV report
end
end

# Usage
report = Report.new(PdfBuilder.new)
report.generate

With the Builder pattern, the Report class is now closed for modification but open for extension. You can add new report formats by creating new builders without changing the existing Report class.

Liskov Substitution Principle (LSP)

Definition: Subclass should add to base class’s behaviour, not replace it.

Subclasses should work seamlessly as a replacement for their parent classes. This ensures that changes to the parent class do not disrupt the functionality of subclasses, making your code more predictable and reliable.

Bad Example:

class PaymentProcessor
def process_payment(amount)
raise NotImplementedError, "Subclasses must implement this method"
end
end

class CreditCardProcessor < PaymentProcessor
def process_payment(amount)
# code to process credit card payment
end
end

class OldPaymentGateway
def pay(amount)
# old way of processing payments
end
end

# Usage
credit_card_processor = CreditCardProcessor.new
old_gateway = OldPaymentGateway.new

# Proper usage
credit_card_processor.process_payment(100) # Output: Processing credit card payment of $100.

# Problematic Usage
begin
# Directly calling a method that does not confirm to PaymentProcessor
old_gateway.process_payment(100) # This will raise an error because `OldPaymentGateway` does not have `process_payment` method
rescue NoMethodError => e
puts "Error: #{e.message}"
end

OldPaymentGateway does not follow the PaymentProcessor interface, leading to errors if used where a PaymentProcessor is expected. This mismatch means that OldPaymentGateway cannot be used wherever a PaymentProcessor is expected.

Improved Example with Adapter Pattern:
The Adapter Pattern allows incompatible interfaces to work together by wrapping one interface with another, making it compatible with the existing system.

Here’s an example to help understand it!

Imagine you have a European plug and need to use it in a US socket. An adapter lets you plug in your European device without changing it. Similarly, the Adapter pattern allows different classes to work together by converting one interface into another.

class PaymentProcessor
def process_payment(amount)
raise NotImplementedError
end
end

class CreditCardProcessor < PaymentProcessor
def process_payment(amount)
# code to process credit card payment
end
end

class OldPaymentGateway
def pay(amount)
# old way of processing payments
end
end

class OldPaymentGatewayAdapter < PaymentProcessor
def initialize(old_gateway)
@old_gateway = old_gateway
end

def process_payment(amount)
@old_gateway.pay(amount)
end
end

# Usage
old_gateway = OldPaymentGateway.new
adapter = OldPaymentGatewayAdapter.new(old_gateway)
processor = CreditCardProcessor.new
processor.process_payment(100)
adapter.process_payment(100)

The Adapter pattern allows OldPaymentGateway to be used seamlessly with PaymentProcessor, maintaining compatibility.

Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they do not use.

Interfaces should be specific to the needs of the clients that use them. This means clients only see what they need and don’t have to deal with unnecessary methods, which makes the code easier to understand and maintain.

Bad Example:

class Notification
def send_email
# code to send email
end

def send_sms
# code to send SMS
end

def send_push
# code to send push notifications
end
end

Clients using Notification might end up with methods they don’t need.

Improved Example with Interactor Pattern:
The Interactor Pattern is a design pattern where an interactor handles a specific use case by coordinating data and business logic, separating it from user interface concerns.

Think of a remote control. Each button (function) is specific to a task, like changing channels or adjusting volume. The Interactor pattern ensures that each class handles a specific function without unnecessary features.

class EmailNotification
def send
# code to send email
end
end

class SmsNotification
def send
# code to send SMS
end
end

class PushNotification
def send
# code to send push notifications
end
end

class SendNotification
def initialize(notification)
@notification = notification
end

def call
@notification.send
end
end

# Usage
SendNotification.new(EmailNotification.new).call
SendNotification.new(SmsNotification.new).call

By creating specific classes for each type of notification and using the SendNotification interactor, you ensure that clients only get what they need, adhering to ISP.

Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules; both should depend on abstractions.

High-level modules (like a report generator) should not be tightly coupled with low-level modules (like a specific database). Both should work with abstract interfaces, allowing for easier changes and greater flexibility.

Bad Example:

class UserRepository
def initialize
@connection = DatabaseConnection.new
end

def find_user(id)
@connection.query("SELECT * FROM users WHERE id = #{id}")
end
end

class DatabaseConnection
def query(sql)
# code to execute SQL query
end
end

UserRepository directly depends on a specific database connection.

Improved Example with Adapter Pattern:
Just as an adapter allows devices with different plugs to work together, this pattern lets high-level modules work with low-level modules through a common interface, promoting flexibility.

class UserRepository
def initialize(connection)
@connection = connection
end

def find_user(id)
@connection.query("SELECT * FROM users WHERE id = #{id}")
end
end

class DatabaseConnection
def query(sql)
# code to execute SQL query
end
end

# Usage
connection = DatabaseConnection.new
repository = UserRepository.new(connection)
repository.find_user(1)

By using an abstract interface (DatabaseConnection), UserRepository can work with any database connection, making the system more flexible and adhering to DIP.

Conclusion

Applying SOLID principles and design patterns in Rails helps you build more robust and maintainable applications. These concepts ensure your code adheres to best practices, reduces technical debt, and simplifies future changes. This blog provides practical examples to enhance your Rails development skills, making your projects more scalable and easier to manage.

Feel free to dive deeper into each principle and pattern to see how they can be applied in your projects for better design and architecture!

--

--

Tushar Adhao
Tushar Adhao

Written by Tushar Adhao

Software artist spreading nuggets of coding gold and sometimes philosophy too.

No responses yet