Skip to content
AgileLogics
7 min read

Design Patterns in Ruby

Prerequisites: Modules and mixins, Test doubles: stubs and mocks

A design pattern is a named, reusable solution to a common design problem. Ruby's blocks, duck typing, and modules make many classic patterns much lighter than in Java or C#. These are the ones you'll see most in Ruby codebases.

Service object

One class, one business operation, one public method (usually call). It keeps controllers and models thin:

class RegisterUser
  Result = Data.define(:success, :user, :error)

  def self.call(...) = new(...).call

  def initialize(email:, repository:)
    @email = email.strip.downcase
    @repository = repository
  end

  def call
    return Result.new(false, nil, "invalid email") unless @email.match?(/\A[^@\s]+@[^@\s]+\z/)
    return Result.new(false, nil, "already registered") if @repository.exists?(@email)

    Result.new(true, @repository.create(@email), nil)
  end
end

class MemoryRepo
  def initialize = @emails = []
  def exists?(email) = @emails.include?(email)
  def create(email) = (@emails << email).last
end

repo = MemoryRepo.new
p RegisterUser.call(email: " Ada@Example.com ", repository: repo).success
p RegisterUser.call(email: "ada@example.com", repository: repo).error
true
"already registered"

def self.call(...) = new(...).call forwards all arguments (...) to new. Returning a small result object instead of raising for expected failures keeps callers simple.

Strategy — with blocks or duck typing

Swap an algorithm at runtime. In Ruby this is often just a lambda or an object with a common method (as in the Polymorphism lesson):

SORTERS = {
  price: ->(items) { items.sort_by { |i| i[:price] } },
  name: ->(items) { items.sort_by { |i| i[:name] } },
}.freeze

items = [{ name: "b", price: 1 }, { name: "a", price: 2 }]
p SORTERS.fetch(:name).call(items).map { |i| i[:name] }
["a", "b"]

Decorator — with SimpleDelegator

Add behavior to an object without changing its class. SimpleDelegator forwards every method you don't define:

require "delegate"

User = Data.define(:first_name, :last_name)

class UserPresenter < SimpleDelegator
  def full_name = "#{first_name} #{last_name}"
end

presenter = UserPresenter.new(User.new("Ada", "Lovelace"))
p presenter.full_name
p presenter.first_name
"Ada Lovelace"
"Ada"

Forwardable (def_delegators :@items, :size, :each) forwards only the methods you list — more explicit, and usually the better choice.

Observer — events

Let objects react to something happening without the source knowing about them. A minimal version with blocks:

class EventBus
  def initialize = @handlers = Hash.new { |h, k| h[k] = [] }
  def on(event, &handler) = @handlers[event] << handler
  def emit(event, payload) = @handlers[event].each { |h| h.call(payload) }
end

bus = EventBus.new
bus.on(:order_placed) { |order| puts "Email receipt for ##{order[:id]}" }
bus.on(:order_placed) { |order| puts "Reserve stock for ##{order[:id]}" }
bus.emit(:order_placed, { id: 7 })
Email receipt for #7
Reserve stock for #7

A word of caution

Patterns are vocabulary, not goals. Reach for one when you feel the problem it solves — a controller doing too much, an if chain over types that keeps growing — not in advance.