SOLID Design Patterns in Rails
Mastering SOLID Principles and Design Patterns in Rails
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.
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.
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.
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.
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.
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!