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.