Ruby Design Patterns That Actually Matter: 5 Patterns Every Ruby Developer Should Know

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.