Let’s be honest: most design pattern tutorials are boring. They show you abstract examples with shapes and animals that have nothing to do with real development work.
After building Ruby applications for years, I’ve learned that while there are dozens of design patterns out there, only a handful actually improve your day-to-day code. Today I want to share the 5 patterns I use most often – with real examples you can steal for your own projects.
These aren’t academic exercises. These are patterns that solve actual problems you face when building Ruby applications.
1. Service Objects: Taming Complex Business Logic
If you’ve ever had a Rails controller method that’s 50 lines long, you need service objects. They extract complex operations into dedicated classes with a single responsibility.
Here’s a realistic example – processing user registrations:
# Before: Fat controller
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
UserMailer.welcome_email(@user).deliver_now
Analytics.track('user_registered', user_id: @user.id)
SlackNotifier.notify_team("New user: #{@user.email}")
if @user.invited_by
InviteCredit.create(user: @user.invited_by, amount: 10)
UserMailer.invite_credit_earned(@user.invited_by).deliver_now
end
redirect_to dashboard_path
else
render :new
end
end
end
# After: Clean service object
class UserRegistrationService
def initialize(user_params, invited_by: nil)
@user_params = user_params
@invited_by = invited_by
end
def call
@user = User.new(@user_params)
return failure(@user.errors) unless @user.save
send_welcome_email
track_analytics
notify_team
handle_invitation_credit if @invited_by
success(@user)
end
private
def send_welcome_email
UserMailer.welcome_email(@user).deliver_now
end
def track_analytics
Analytics.track('user_registered', user_id: @user.id)
end
def notify_team
SlackNotifier.notify_team("New user: #{@user.email}")
end
def handle_invitation_credit
InviteCredit.create(user: @invited_by, amount: 10)
UserMailer.invite_credit_earned(@invited_by).deliver_now
end
def success(user)
{ success: true, user: user }
end
def failure(errors)
{ success: false, errors: errors }
end
end
# Clean controller
class UsersController < ApplicationController
def create
result = UserRegistrationService.new(user_params, invited_by: current_user).call
if result[:success]
redirect_to dashboard_path
else
@user = User.new(user_params)
@user.errors.merge!(result[:errors])
render :new
end
end
end
Now your controller is focused on HTTP concerns, and your business logic is testable in isolation. Plus, you can reuse this service from background jobs, rake tasks, or anywhere else.
2. Command Pattern: Undoable Operations
The Command pattern wraps operations in objects. It's perfect for building features like undo/redo, queuing operations, or logging actions.
Here's how I implemented it for a document editor:
class Command
def execute
raise NotImplementedError
end
def undo
raise NotImplementedError
end
end
class InsertTextCommand < Command
def initialize(document, position, text)
@document = document
@position = position
@text = text
end
def execute
@document.insert(@position, @text)
end
def undo
@document.delete(@position, @text.length)
end
end
class DeleteTextCommand < Command
def initialize(document, position, length)
@document = document
@position = position
@length = length
@deleted_text = nil
end
def execute
@deleted_text = @document.content[@position, @length]
@document.delete(@position, @length)
end
def undo
@document.insert(@position, @deleted_text) if @deleted_text
end
end
class Document
attr_accessor :content
def initialize
@content = ""
@command_history = []
@current_position = -1
end
def execute_command(command)
# Remove any commands after current position (for redo scenarios)
@command_history = @command_history[0..@current_position]
command.execute
@command_history << command
@current_position += 1
end
def undo
return false if @current_position < 0
@command_history[@current_position].undo
@current_position -= 1
true
end
def redo
return false if @current_position >= @command_history.length - 1
@current_position += 1
@command_history[@current_position].execute
true
end
def insert(position, text)
@content.insert(position, text)
end
def delete(position, length)
@content.slice!(position, length)
end
end
# Usage
doc = Document.new
doc.execute_command(InsertTextCommand.new(doc, 0, "Hello"))
doc.execute_command(InsertTextCommand.new(doc, 5, " World"))
puts doc.content # => "Hello World"
doc.undo
puts doc.content # => "Hello"
doc.redo
puts doc.content # => "Hello World"
This pattern makes your operations first-class objects. You can log them, queue them, or even serialize them to replay user actions later.
3. Factory Pattern: Smart Object Creation
Factories handle complex object creation logic. They're especially useful when you need different object types based on input, or when object creation involves multiple steps.
Here's a real example from a payment processing system:
class PaymentProcessorFactory
PROCESSORS = {
'stripe' => 'StripeProcessor',
'paypal' => 'PaypalProcessor',
'square' => 'SquareProcessor'
}.freeze
def self.create(provider, amount, currency = 'USD')
processor_class = PROCESSORS[provider.downcase]
raise "Unknown payment provider: #{provider}" unless processor_class
# Convert string to class
klass = Object.const_get(processor_class)
# Each processor might need different initialization
case provider.downcase
when 'stripe'
klass.new(
api_key: ENV['STRIPE_SECRET_KEY'],
amount: amount,
currency: currency
)
when 'paypal'
klass.new(
client_id: ENV['PAYPAL_CLIENT_ID'],
client_secret: ENV['PAYPAL_CLIENT_SECRET'],
amount: amount,
currency: currency,
environment: Rails.env.production? ? 'production' : 'sandbox'
)
when 'square'
klass.new(
access_token: ENV['SQUARE_ACCESS_TOKEN'],
application_id: ENV['SQUARE_APPLICATION_ID'],
amount: amount,
currency: currency
)
end
end
def self.available_providers
PROCESSORS.keys
end
end
# Base processor interface
class PaymentProcessor
attr_reader :amount, :currency
def initialize(amount:, currency:, **options)
@amount = amount
@currency = currency
@options = options
end
def process
raise NotImplementedError
end
end
class StripeProcessor < PaymentProcessor
def process
# Stripe-specific logic
puts "Processing $#{amount} via Stripe"
{ success: true, transaction_id: "stripe_#{rand(1000)}" }
end
end
class PaypalProcessor < PaymentProcessor
def process
# PayPal-specific logic
puts "Processing $#{amount} via PayPal"
{ success: true, transaction_id: "paypal_#{rand(1000)}" }
end
end
# Usage
processor = PaymentProcessorFactory.create('stripe', 29.99)
result = processor.process
# => "Processing $29.99 via Stripe"
# Easy to switch providers
processor = PaymentProcessorFactory.create('paypal', 29.99)
result = processor.process
# => "Processing $29.99 via PayPal"
The factory handles all the messy initialization logic and gives you a clean interface. Adding new payment providers becomes trivial.
4. Observer Pattern: Decoupled Event Handling
The Observer pattern lets objects communicate without being tightly coupled. It's perfect for handling side effects or building event-driven systems.
Here's how I used it for a blog system where multiple things need to happen when a post is published:
module Observable
def initialize
@observers = []
super if defined?(super)
end
def add_observer(observer)
@observers << observer unless @observers.include?(observer)
end
def remove_observer(observer)
@observers.delete(observer)
end
def notify_observers(event, data = {})
@observers.each { |observer| observer.update(event, data) }
end
end
class BlogPost
include Observable
attr_accessor :title, :content, :status, :author
def initialize(title:, content:, author:)
@title = title
@content = content
@author = author
@status = 'draft'
super()
end
def publish!
@status = 'published'
@published_at = Time.now
# Notify all observers
notify_observers(:post_published, {
post: self,
author: @author,
published_at: @published_at
})
end
end
class EmailNotifier
def update(event, data)
case event
when :post_published
send_notification_email(data[:post], data[:author])
end
end
private
def send_notification_email(post, author)
puts "📧 Sending email: '#{post.title}' published by #{author}"
# EmailService.send_notification(post, author)
end
end
class SocialMediaPublisher
def update(event, data)
case event
when :post_published
share_on_social_media(data[:post])
end
end
private
def share_on_social_media(post)
puts "📱 Sharing on social: '#{post.title}'"
# TwitterAPI.post_tweet(post.title, post.url)
end
end
class AnalyticsTracker
def update(event, data)
case event
when :post_published
track_publication(data[:post], data[:author])
end
end
private
def track_publication(post, author)
puts "📊 Tracking: Post published by #{author}"
# Analytics.track('post_published', post_id: post.id)
end
end
# Usage
post = BlogPost.new(
title: "My New Ruby Article",
content: "Lorem ipsum...",
author: "Alice"
)
# Set up observers
post.add_observer(EmailNotifier.new)
post.add_observer(SocialMediaPublisher.new)
post.add_observer(AnalyticsTracker.new)
# Publish the post
post.publish!
# => 📧 Sending email: 'My New Ruby Article' published by Alice
# => 📱 Sharing on social: 'My New Ruby Article'
# => 📊 Tracking: Post published by Alice
The beauty here is that your BlogPost class doesn't need to know about emails, social media, or analytics. You can add or remove observers without changing the core post logic.
5. Decorator Pattern: Adding Behavior Without Inheritance
Decorators let you add functionality to objects dynamically. They're great for features like caching, logging, or adding presentation logic.
Here's how I used decorators to add different formatting options to text content:
class TextContent
attr_reader :content
def initialize(content)
@content = content
end
def display
@content
end
end
class TextDecorator
def initialize(text_object)
@text_object = text_object
end
def display
@text_object.display
end
# Delegate other methods to the wrapped object
def method_missing(method_name, *args, &block)
@text_object.send(method_name, *args, &block)
end
def respond_to_missing?(method_name, include_private = false)
@text_object.respond_to?(method_name, include_private) || super
end
end
class MarkdownDecorator < TextDecorator
def display
convert_markdown(@text_object.display)
end
private
def convert_markdown(text)
text
.gsub(/\*\*(.*?)\*\*/, '\1')
.gsub(/\*(.*?)\*/, '\1')
.gsub(/`(.*?)`/, '\1
')
end
end
class CacheDecorator < TextDecorator
def initialize(text_object, cache_key)
super(text_object)
@cache_key = cache_key
@cache = {}
end
def display
@cache[@cache_key] ||= @text_object.display
end
end
class LoggingDecorator < TextDecorator
def display
puts "Displaying content: #{@text_object.content[0..20]}..."
@text_object.display
end
end
class HtmlSafeDecorator < TextDecorator
def display
escape_html(@text_object.display)
end
private
def escape_html(text)
text
.gsub('&', '&')
.gsub('<', '<')
.gsub('>', '>')
.gsub('"', '"')
end
end
# Usage - you can stack decorators!
content = TextContent.new("This is **bold** and *italic* text with `code`")
# Just basic content
puts content.display
# => "This is **bold** and *italic* text with `code`"
# Add markdown processing
markdown_content = MarkdownDecorator.new(content)
puts markdown_content.display
# => "This is bold and italic text with code
"
# Add caching to the markdown version
cached_content = CacheDecorator.new(markdown_content, "post_123")
puts cached_content.display # First call processes and caches
puts cached_content.display # Second call uses cache
# Stack multiple decorators
safe_logged_markdown = HtmlSafeDecorator.new(
LoggingDecorator.new(
MarkdownDecorator.new(content)
)
)
puts safe_logged_markdown.display
# => Displaying content: This is **bold** and *i...
# => "This is <strong>bold</strong> and <em>italic</em> text with <code>code</code>"
Decorators shine because you can mix and match behaviors. Need cached, logged, HTML-safe markdown? Just stack the decorators in any order.
6. Factory Method: When Simple Factories Aren't Enough
Sometimes you need more flexibility than a basic factory. The Factory Method pattern lets subclasses decide what to create.
I used this pattern for a notification system that needed different delivery strategies:
class NotificationSender
def send_notification(user, message)
delivery_method = create_delivery_method(user)
delivery_method.deliver(message)
end
private
# This is the factory method - subclasses will override this
def create_delivery_method(user)
raise NotImplementedError
end
end
class EmailNotificationSender < NotificationSender
private
def create_delivery_method(user)
if user.premium?
PriorityEmailDelivery.new(user.email)
else
StandardEmailDelivery.new(user.email)
end
end
end
class SMSNotificationSender < NotificationSender
private
def create_delivery_method(user)
if user.country == 'US'
TwilioSMSDelivery.new(user.phone)
else
NexmoSMSDelivery.new(user.phone)
end
end
end
class PushNotificationSender < NotificationSender
private
def create_delivery_method(user)
case user.device_type
when 'ios'
APNSDelivery.new(user.device_token)
when 'android'
FCMDelivery.new(user.device_token)
else
WebPushDelivery.new(user.browser_subscription)
end
end
end
# Delivery method implementations
class StandardEmailDelivery
def initialize(email)
@email = email
end
def deliver(message)
puts "📧 Standard email to #{@email}: #{message}"
end
end
class PriorityEmailDelivery
def initialize(email)
@email = email
end
def deliver(message)
puts "⚡ Priority email to #{@email}: #{message}"
end
end
# Usage
user = OpenStruct.new(email: "user@example.com", premium?: true)
sender = EmailNotificationSender.new
sender.send_notification(user, "Your order has shipped!")
# => ⚡ Priority email to user@example.com: Your order has shipped!
Each sender type can have its own logic for choosing the right delivery method, but they all follow the same interface.
When to Use Each Pattern
Service Objects: When your controllers or models are getting fat. Extract complex business operations.
Command Pattern: When you need to queue, log, or undo operations. Perfect for background jobs and audit trails.
Factory Pattern: When object creation is complex or depends on runtime conditions.
Observer Pattern: When multiple objects need to react to the same event without coupling.
Decorator Pattern: When you need to add behavior to objects dynamically or layer multiple behaviors.
Patterns to Avoid in Ruby
Not every pattern from the Gang of Four book makes sense in Ruby. Here are some I rarely use:
Singleton: Ruby modules often work better, and global state is usually a code smell anyway.
Abstract Factory: Usually overkill in Ruby. Simple factories or even hashes work fine.
Visitor: Ruby's duck typing and blocks make this unnecessary most of the time.
A Pattern Implementation Checklist
Before implementing any pattern, ask yourself:
1. Does this solve a real problem? Don't add patterns just because you can.
2. Is it testable? Good patterns make testing easier, not harder.
3. Is it readable? Your future self should understand it in 6 months.
4. Does it reduce coupling? Patterns should make your code more modular.
Testing Your Patterns
Here's a quick example of testing the Service Object pattern:
RSpec.describe UserRegistrationService do
let(:user_params) { { name: "John", email: "john@example.com" } }
describe "#call" do
context "with valid params" do
it "creates a user and sends welcome email" do
expect(UserMailer).to receive(:welcome_email).and_return(double(deliver_now: true))
expect(Analytics).to receive(:track)
result = UserRegistrationService.new(user_params).call
expect(result[:success]).to be true
expect(result[:user]).to be_persisted
end
end
context "with invalid params" do
let(:user_params) { { name: "", email: "invalid" } }
it "returns failure with errors" do
result = UserRegistrationService.new(user_params).call
expect(result[:success]).to be false
expect(result[:errors]).to be_present
end
end
end
end
Testing service objects is straightforward because they have clear inputs and outputs. Much easier than testing fat controllers!
Final Thoughts
Design patterns aren't about writing clever code. They're about solving common problems in ways that make your codebase easier to understand and maintain.
Start with Service Objects – they'll immediately improve your Rails controllers. Once you're comfortable with those, try the Observer pattern for handling side effects cleanly.
The key is to implement patterns when you actually need them, not because a blog post told you to. Your code will tell you when it's ready for a pattern.