Design Patterns in Rails
A guide to understanding essential Design Patterns
In software development, design patterns are standard solutions to commonly occurring problems. They provide proven techniques for creating software architectures that are easy to maintain, extend, and scale. By leveraging design patterns, developers can write code that is cleaner, more reusable, and easier to modify in the long term.
In this blog post, we’ll take a deep dive into some of the most essential design patterns used in object-oriented software development: Builder, Adapter, Observer, Singleton, Factory Method, Strategy, Decorator, Prototype, Facade, and others. For each pattern, we’ll break down its purpose, explain when to use it, and provide a simple code example in Ruby.
1. Builder Pattern
The Builder pattern is used to construct complex objects step by step. It allows you to create an object in a modular way, ensuring that the object is built in a consistent manner. This pattern is useful when an object needs to be created with many possible configurations and combinations of attributes.
When to use:
- When you need to construct an object with many optional parameters.
- When an object needs to be created step by step rather than all at once.
- When there are multiple representations of an object (e.g., different formats).
Structure:
- Builder: An interface or abstract class defining methods for creating parts of the object.
- ConcreteBuilder: A class that implements the Builder interface and assembles the parts.
- Director: A class that directs the construction process, guiding the builder to construct the object in a particular sequence.
- Product: The complex object being constructed.
Example in Ruby (Car Builder)
# Product
class Car
attr_accessor :wheels, :engine, :color, :gps
def initialize(wheels, engine, color, gps)
@wheels = wheels
@engine = engine
@color = color
@gps = gps
end
end
# Builder
class CarBuilder
def initialize
@wheels = "4"
@engine = "V6"
@color = "White"
@gps = false
end
def set_wheels(wheels)
@wheels = wheels
self
end
def set_engine(engine)
@engine = engine
self
end
def set_color(color)
@color = color
self
end
def set_gps(gps)
@gps = gps
self
end
def build
Car.new(@wheels, @engine, @color, @gps)
end
end
# Director
class CarDirector
def initialize(builder)
@builder = builder
end
def construct_sedan
@builder.set_wheels("4").set_engine("V6").set_color("Black").set_gps(true).build
end
def construct_suv
@builder.set_wheels("4").set_engine("V8").set_color("Blue").set_gps(false).build
end
end
# Client
builder = CarBuilder.new
director = CarDirector.new(builder)
sedan = director.construct_sedan
puts sedan.inspect # Car with 4 wheels, V6 engine, black color, GPS
suv = director.construct_suv
puts suv.inspect # Car with 4 wheels, V8 engine, blue color, no GPS
Explanation:
- The CarBuilder class is responsible for setting various properties of the car, step by step.
- The CarDirector class manages the construction process and tells the builder how to construct different types of cars (like sedan or SUV).
- The Car class represents the product (the object being built).
2. Adapter Pattern
The Adapter pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces by wrapping one of the interfaces and converting its methods into something the other interface can understand.
When to use:
- When you need to integrate an existing class into a system that expects a different interface.
- When you want to reuse legacy code with a new system without modifying the legacy code.
Structure:
- Target: The interface that the client expects to interact with.
- Adapter: A class that converts the interface of the adaptee into the target interface.
- Adaptee: The existing class that needs to be adapted.
- Client: The class that interacts with the target interface.
Example in Ruby (Media Player Adapter)
# Target Interface
class MediaPlayer
def play_audio(file)
raise NotImplementedError, "This method should be overridden."
end
end
# Adaptee (Old/Legacy Code)
class MediaAdapter
def initialize(audio_player)
@audio_player = audio_player
end
def play_audio(file)
@audio_player.play(file)
end
end
# Adaptee (Legacy class that doesn't fit with the target interface)
class AudioPlayer
def play(file)
puts "Playing audio: #{file}"
end
end
# Client using Target interface
class MediaClient
def initialize(player)
@player = player
end
def play_media(file)
@player.play_audio(file)
end
end
# Client
audio_player = AudioPlayer.new
adapter = MediaAdapter.new(audio_player)
client = MediaClient.new(adapter)
client.play_media("song.mp3") # Output: Playing audio: song.mp3
Explanation:
- The MediaPlayer is the target interface that the client expects.
- The AudioPlayer is an existing (legacy) class that doesn’t match the required interface.
- The MediaAdapter is used to wrap the AudioPlayer and adapt its interface so that it can be used by the MediaClient.
3. Observer Pattern
The Observer pattern is used when one object (the subject) changes state and all dependent objects (observers) need to be notified and updated automatically. It allows for loose coupling between the subject and observers.
When to use:
- When multiple objects need to react to changes in a single object without tightly coupling them.
- Commonly used in UI frameworks, event handling systems, or messaging systems.
Structure:
- Subject: The object being observed. It keeps track of all the observers and notifies them when its state changes.
- Observer: An interface or abstract class for receiving updates from the subject.
- ConcreteSubject: The actual implementation of the subject.
- ConcreteObserver: The actual implementation of the observer, which updates itself when the subject’s state changes.
Example in Ruby (Weather Station)
# Observer Interface
class Observer
def update(state)
raise NotImplementedError, "This method should be overridden."
end
end
# Subject Interface
class Subject
def add_observer(observer)
raise NotImplementedError, "This method should be overridden."
end
def remove_observer(observer)
raise NotImplementedError, "This method should be overridden."
end
def notify_observers
raise NotImplementedError, "This method should be overridden."
end
end
# Concrete Subject (WeatherStation)
class WeatherStation < Subject
attr_accessor :temperature
def initialize
@observers = []
@temperature = 0
end
def add_observer(observer)
@observers << observer
end
def remove_observer(observer)
@observers.delete(observer)
end
def notify_observers
@observers.each { |observer| observer.update(@temperature) }
end
def set_temperature(temp)
@temperature = temp
notify_observers
end
end
# Concrete Observer (TemperatureDisplay)
class TemperatureDisplay < Observer
def update(state)
puts "The current temperature is: #{state}°C"
end
end
# Client
weather_station = WeatherStation.new
temp_display = TemperatureDisplay.new
weather_station.add_observer(temp_display)
weather_station.set_temperature(25) # Output: The current temperature is: 25°C
weather_station.set_temperature(30) # Output: The current temperature is: 30°C
Explanation:
- The WeatherStation class is the subject that holds the state (temperature) and notifies observers (like TemperatureDisplay) whenever it changes.
- The TemperatureDisplay class implements the Observer interface and updates itself whenever the temperature changes.
4. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance. It is used when you need a single shared resource (like a configuration manager or logging system) throughout the application.
When to use:
- When you need to control access to shared resources (e.g., database connections, configuration settings).
- When you want to ensure only one instance of a class exists throughout the application.
Structure:
Singleton: A class that is responsible for managing its single instance and providing access to it.
Example in Ruby (Database Connection)
class DatabaseConnection
# Singleton class that ensures only one instance of the connection exists
@instance = nil
def self.instance
@instance ||= DatabaseConnection.new
end
def initialize
raise "Use .instance to access this class" if @instance
@connection = "Database Connection Established"
end
def connect
@connection
end
end
# Client
db1 = DatabaseConnection.instance
db2 = DatabaseConnection.instance
puts db1.connect # Output: Database Connection Established
puts db1 == db2 # Output: true (both references point to the same instance)
Explanation:
- The
DatabaseConnection
class ensures only one instance is created by using the class variable@instance
. DatabaseConnection.instance
gives access to the single instance. If it’s not already created, it initializes one.
5. Factory Method Pattern
The Factory Method pattern defines an interface for creating objects but allows subclasses to alter the type of objects that will be created. It’s a way to delegate the responsibility of object creation to child classes.
When to use:
- When you want to create objects of a class without specifying the exact class to instantiate.
- When you want to provide a way to allow subclasses to specify the objects to create.
Structure:
- Creator: An abstract class that declares the factory method.
- ConcreteCreator: A subclass that implements the factory method.
- Product: The object that is being created.
- ConcreteProduct: The specific product created by the factory method.
Example in Ruby (Shape Factory)
# Product Interface
class Shape
def draw
raise NotImplementedError
end
end
# Concrete Product: Circle
class Circle < Shape
def draw
puts "Drawing Circle"
end
end
# Concrete Product: Square
class Square < Shape
def draw
puts "Drawing Square"
end
end
# Creator (Factory)
class ShapeFactory
def create_shape(type)
case type
when :circle
Circle.new
when :square
Square.new
else
raise "Unknown shape type"
end
end
end
# Client
factory = ShapeFactory.new
circle = factory.create_shape(:circle)
circle.draw # Output: Drawing Circle
square = factory.create_shape(:square)
square.draw # Output: Drawing Square
Explanation:
- The
ShapeFactory
class is responsible for creating objects of typeCircle
orSquare
based on input. - This pattern allows the client to create shapes without needing to know the exact class to instantiate.
6. Strategy Pattern
The Strategy pattern allows a family of algorithms to be defined and encapsulated inside classes. The client can choose the appropriate algorithm at runtime, promoting flexibility and interchangeability of behaviors.
When to use:
- When you have multiple algorithms for a specific task (e.g., sorting, searching) and want to make them interchangeable.
- When you want to delegate the decision of which algorithm to use to an external class.
Example in Ruby (Payment Strategy)
# Strategy Interface
class PaymentStrategy
def pay(amount)
raise NotImplementedError
end
end
# Concrete Strategies
class CreditCardPayment < PaymentStrategy
def pay(amount)
puts "Paid #{amount} using Credit Card"
end
end
class PayPalPayment < PaymentStrategy
def pay(amount)
puts "Paid #{amount} using PayPal"
end
end
# Context
class ShoppingCart
def initialize(payment_strategy)
@payment_strategy = payment_strategy
end
def checkout(amount)
@payment_strategy.pay(amount)
end
end
# Client
cart = ShoppingCart.new(CreditCardPayment.new)
cart.checkout(100) # Output: Paid 100 using Credit Card
cart = ShoppingCart.new(PayPalPayment.new)
cart.checkout(200) # Output: Paid 200 using PayPal
Explanation:
- The
PaymentStrategy
interface defines the methodpay
. CreditCardPayment
andPayPalPayment
are concrete strategies that implement this method.ShoppingCart
is the context that uses a specific payment strategy.
7. Decorator Pattern
The Decorator pattern allows behaviors to be added to individual objects, dynamically, without affecting the behavior of other objects from the same class.
When to use:
- When you want to add responsibilities to objects in a flexible and reusable manner.
- When subclassing is not ideal or would lead to an explosion of classes.
Example in Ruby (Coffee with Add-ons)
# Component Interface
class Coffee
def cost
raise NotImplementedError
end
end
# Concrete Component: Simple Coffee
class SimpleCoffee < Coffee
def cost
5
end
end
# Decorator
class CoffeeDecorator < Coffee
def initialize(coffee)
@coffee = coffee
end
end
# Concrete Decorator: MilkDecorator
class MilkDecorator < CoffeeDecorator
def cost
@coffee.cost + 1
end
end
# Concrete Decorator: SugarDecorator
class SugarDecorator < CoffeeDecorator
def cost
@coffee.cost + 0.5
end
end
# Client
coffee = SimpleCoffee.new
puts coffee.cost # Output: 5
coffee_with_milk = MilkDecorator.new(coffee)
puts coffee_with_milk.cost # Output: 6
coffee_with_milk_and_sugar = SugarDecorator.new(coffee_with_milk)
puts coffee_with_milk_and_sugar.cost # Output: 6.5
Explanation:
SimpleCoffee
is the basic component, whileMilkDecorator
andSugarDecorator
are decorators that add functionality (like additional cost).- You can add behavior dynamically without modifying the base class.
8. Prototype Pattern
The Prototype pattern allows objects to be created by cloning an existing object, rather than creating new instances from scratch. This pattern is particularly useful when object creation is costly or complicated.
When to use:
- When the cost of creating a new instance is more expensive than copying an existing instance.
- When you want to avoid the overhead of re-creating similar objects multiple times.
Example in Ruby (Cloning a Car)
# Prototype
class Prototype
def clone
raise NotImplementedError
end
end
# ConcretePrototype: Car
class Car < Prototype
attr_accessor :make, :model
def initialize(make, model)
@make = make
@model = model
end
def clone
Car.new(@make, @model)
end
end
# Client
car1 = Car.new("Toyota", "Camry")
car2 = car1.clone
puts car2.make # Output: Toyota
puts car2.model # Output: Camry
Explanation:
Car
is the concrete prototype that implements theclone
method, which creates a new car with the same attributes.- The
clone
method is used to create an exact copy of an existing object.
9. Facade Pattern
The Facade pattern provides a simplified interface to a complex system of classes, making it easier for the client to interact with that system. It hides the complexities of the subsystem from the client.
When to use:
- When you need to simplify interactions with a complex system of classes or services.
- When you want to provide a unified interface for a set of APIs or services.
Example in Ruby (Home Theater System)
# Subsystem Classes
class Amplifier
def on
puts "Amplifier on"
end
end
class DVDPlayer
def on
puts "DVD Player on"
end
end
class Projector
def on
puts "Projector on"
end
end
# Facade
class HomeTheaterFacade
def initialize(amp, dvd, projector)
@amp = amp
@dvd = dvd
@projector = projector
end
def watch_movie
@amp.on
@dvd.on
@projector.on
puts "Enjoy the movie!"
end
end
# Client
amp = Amplifier.new
dvd = DVDPlayer.new
projector = Projector.new
home_theater = HomeTheaterFacade.new(amp, dvd, projector)
home_theater.watch_movie
# Output:
# Amplifier on
# DVD Player on
# Projector on
# Enjoy the movie!
Explanation: The HomeTheaterFacade
class simplifies the interaction with complex subsystems (e.g., Amplifier
, DVDPlayer
, Projector
) by providing a unified interface. The client interacts with the HomeTheaterFacade
, which internally handles the complexity.
Certainly! Here’s the Interactor Pattern explained in the same format as the previous design patterns:
10. Interactor Pattern
The Interactor Pattern is used to organize and encapsulate complex business logic or use case workflows that don’t naturally fit within the models or controllers. By using the interactor, you can separate these concerns from the controller, keeping your application’s code clean, maintainable, and easier to test.
When to use:
- When your controller becomes too large or complex due to business logic.
- When you want to separate business logic from controllers and models.
- When you need to perform a sequence of operations as part of a single use case (e.g., creating a user and sending a welcome email).
Example:
Let’s assume you want to handle user sign-ups, which involves both creating a new user and sending a welcome email. Instead of doing all this logic directly in the controller, you can delegate it to an interactor.
Step 1: Define the Interactor
class UserSignupInteractor
include Interactor
def call
# Create the user based on the parameters passed in context
user = User.new(user_params)
if user.save
send_welcome_email(user)
context.user = user # Return the created user in the context
else
context.fail!(error: "Failed to create user") # Fail the interactor if something goes wrong
end
end
private
def user_params
context.params.require(:user).permit(:name, :email, :password) # Safely access user params
end
def send_welcome_email(user)
# Logic to send a welcome email
WelcomeMailer.with(user: user).send_welcome_email.deliver_now
end
end
Step 2: Call the Interactor in the Controller
class UsersController < ApplicationController
def create
# Use the interactor to handle the business logic
result = UserSignupInteractor.call(params: user_params)
if result.success?
# Redirect to the user profile if successful
redirect_to user_path(result.user), notice: "Welcome!"
else
# Show the error message if something goes wrong
flash[:alert] = result.error
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password) # Permit necessary parameters
end
end
Explanation:
- UserSignupInteractor: This interactor handles the entire process of signing up a user. It performs the steps of creating the user, saving it, and then sending the welcome email. If any part of the process fails, it stops and provides an error message.
- Controller: The controller simply calls the interactor and handles the response. It doesn’t need to be concerned with the business logic of creating a user and sending an email.
- The interactor returns a result object, which contains the status (success or failure) and any relevant data (like the user or an error message).
Conclusion
Design patterns are powerful tools that help developers solve common software design problems in a systematic way. By using these patterns, you can make your code cleaner, more flexible, and easier to maintain.
Understanding and implementing these patterns in your projects will improve your software design skills and give you more efficient solutions for the challenges you face. Happy coding!