SOLID and Design Patterns
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
SOLID and the Gang of Four catalogue were written for languages with static interfaces, abstract classes and
verbose wiring. Ruby keeps the ideas and drops most of the ceremony: an interface is "the methods this object
responds to", a strategy is a block, a factory is a method, an iterator is each plus Enumerable. This page shows each
principle and each pattern in the form a Ruby programmer actually writes, on the types of the running shelf library
catalogue (Isbn, Author, Book, Catalog, Store::Memory), and says when a pattern is not worth writing at all.
The page builds on Classes and Objects, Modules and Mixins, Blocks, Procs and Lambdas and Visibility and Encapsulation. Naming, small methods and refactoring are in Idioms, Style and Clean Code; how these ideas combine into layers is in Application Architecture in Plain Ruby; typed interfaces are in Type Checking with RBS, TypeProf, Steep and Sorbet; and the traps that make several of these designs fail are in Common Mistakes and Gotchas. For the same patterns in other languages see Java, C#, Rust and Python.
Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop
1.92 using the configuration of the shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The
text in # => comments is the real output. Each file is self-contained and run with ruby -Ilib so that require "shelf"
finds the gem (see the shelf gem source). Libraries that are not part of the core are labelled: delegate, forwardable and singleton are default
gems that ship with Ruby; observer is a bundled gem (a default gem until Ruby 3.3, a bundled gem since 3.4), which matters
under Bundler (see Observer).
Principles
YAGNI, KISS and DRY
-
YAGNI ("you aren’t gonna need it") says to build for the requirement you have. Ruby makes it cheap to follow, because adding an
attr_reader, extracting a module or introducing a class later is a small, local change that tests protect (see Testing with Minitest). The classic violation is a plugin system, a configuration DSL or a strategy hierarchy introduced for a second implementation that never arrives. Wait for the second real case; the third makes the shape obvious. -
KISS ("keep it simple") prefers the standard library’s name for an idea over a hand-written version of it. A range check written as an
if/elsifchain is aclamp; a manual accumulator loop issum,to_horeach_with_object. -
DRY ("don’t repeat yourself") is about repeated knowledge, not repeated characters. A late-fee rule that appears in a controller, a report and a test is a bug waiting to diverge; two methods that merely look alike today but change for different reasons are not duplication, and merging them couples things that should stay apart.
# frozen_string_literal: true
require "shelf"
# KISS: Comparable#clamp states the intent that a hand-written if/elsif chain hides.
def loan_days(requested) = requested.clamp(1, 28)
loan_days(0) # => 1
loan_days(14) # => 14
loan_days(90) # => 28
# DRY: the late-fee rule lives in one place and every caller uses it.
module Shelf
module Fines
CENTS_PER_DAY = 25
MAX_CENTS = 1000
def self.for(days_late) = (days_late * CENTS_PER_DAY).clamp(0, MAX_CENTS)
end
end
Shelf::Fines.for(-3) # => 0
Shelf::Fines.for(4) # => 100
Shelf::Fines.for(400) # => 1000
Notice that Shelf::Fines is a module with a module function rather than a class: it holds one rule and no state, so
there is nothing to instantiate. Putting a rule in a single named place is the DRY step; the next sections show how
to keep that place replaceable.
SOLID at a glance
SOLID is five design guidelines for object-oriented code that must change safely. In Ruby they reduce to a few habits: give each class one reason to change, add behaviour by adding objects rather than editing conditionals, keep subclasses and substitutes honest about the contract, ask collaborators for as little as possible, and receive collaborators instead of constructing them.
| Principle | Ruby form | Typical smell |
|---|---|---|
Single responsibility |
A class that stores, a class that formats, a module that holds one rule |
A model that validates, persists, formats and emails |
Open/closed |
Pass an object that responds to a method (a formatter, a policy, a block); add a new object instead of a new |
A |
Liskov substitution |
Any object with the same messages and compatible behaviour can stand in; subclasses keep the superclass contract |
A subclass that raises in an inherited method or ignores an argument |
Interface segregation |
Small roles named by the methods a consumer calls ( |
Passing a large object where one method is used, or |
Dependency inversion |
Constructor injection with a default keyword argument; blocks and lambdas for behaviour |
|
Single responsibility
A class should have one reason to change. Shelf::Catalog keeps the books and guards them with a mutex; how the
books are shown (text, JSON, a table) is a different reason to change, so it lives elsewhere. The report below is
constructed from a catalog and a formatter and does not know how either works internally. This also demonstrates the
open/closed principle, treated next: a new format is a new object.
# frozen_string_literal: true
require "json"
require "shelf"
module Shelf
# Single responsibility: Catalog keeps books; Report decides how to show them.
# Open/closed: a new output format is a new formatter object, Report never changes.
class Report
def initialize(catalog, formatter:)
@catalog = catalog
@formatter = formatter
end
def render = @formatter.format(@catalog.to_a)
end
class TextFormatter
def format(books) = books.join("\n")
end
class JsonFormatter
def format(books)
JSON.generate(books.map { |book| { title: book.title, author: book.author.name } })
end
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new(store: Shelf::Store::Memory.new).add(book)
Shelf::Report.new(catalog, formatter: Shelf::TextFormatter.new).render # => "The Dispossessed by Ursula K. Le Guin"
json = Shelf::Report.new(catalog, formatter: Shelf::JsonFormatter.new).render
JSON.parse(json) # => [{"title" => "The Dispossessed", "author" => "Ursula K. Le Guin"}]
# A new format needs no edit to Report or to the existing formatters.
titles_only = Object.new
def titles_only.format(books) = books.map(&:title).join(", ")
Shelf::Report.new(catalog, formatter: titles_only).render # => "The Dispossessed"
Report calls @catalog.to_a, which Enumerable provides because Catalog defines each; it never touches the
store. A useful test for "one responsibility" is to describe the class in one sentence without the word "and". Another is
to list the reasons the file would be edited: if a change to the report layout and a change to the locking strategy
both edit Catalog, it has two.
Open/closed
Software entities should be open for extension and closed for modification. The closed-for-modification part is
about the stable core (Report#render); the open part is the seam where behaviour varies (formatter:). In a
statically typed language the seam is an interface or abstract class. In Ruby the seam is a method name: any object with
format works, including the throwaway titles_only object above that was created with Object.new and a singleton
method (see
Singleton Classes and Class-Level Behavior).
The smell this removes is the type switch:
def format(books, kind)
case kind
when :text then books.join("\n")
when :json then JSON.generate(books.map(&:title))
end # every new format edits this method, and kind typos silently return nil
end
Two cautions apply. First, do not apply the principle to variation that does not exist: a case with two stable
branches is clearer than two classes and a registry (YAGNI). Second, an open seam is also an attack surface for
mistakes: extension points need a documented contract (a comment, a type signature, a contract test) because Ruby will not
check it for you.
Liskov substitution
Objects of a subtype must be usable wherever the supertype is expected without surprising the caller. In Ruby this
applies to subclasses and to duck-typed substitutes, since anything passed as a store: is a substitute for
Store::Memory. The rules, in Ruby terms:
-
A method accepts at least what the original accepted (do not make optional arguments required or drop keywords).
-
A method returns something the caller can use the same way (same messages understood, same truthiness meaning).
-
A method raises no new kinds of errors that callers cannot reasonably handle, and respects the original’s side effects.
-
superis called where the superclass does essential work (see Method Lookup and super).
The example shows a subclass that breaks the contract and the Interface Segregation fix, which is the usual cure: if a consumer needs a smaller role, give it a smaller object instead of a subclass that disables part of the parent.
# frozen_string_literal: true
require "shelf"
# Violates Liskov: it is-a Memory store, but add no longer honours the contract.
class ReadOnlyStore < Shelf::Store::Memory
def add(_book) = raise(Shelf::Error, "read-only store")
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
begin
Shelf::Catalog.new(store: ReadOnlyStore.new).add(book)
rescue Shelf::Error => e
e.message # => "read-only store"
end
# Interface segregation: a reader role needs only #all, so it can be a separate,
# smaller object instead of a subclass that breaks the wider contract.
Snapshot = Struct.new(:all)
reader = Snapshot.new([book].freeze)
reader.respond_to?(:all) # => true
reader.respond_to?(:add) # => false
def titles(source)
raise ArgumentError, "#{source.class} does not respond to #all" unless source.respond_to?(:all)
source.all.map(&:title)
end
titles(reader) # => ["The Dispossessed"]
titles(Shelf::Store::Memory.new.add(book)) # => ["The Dispossessed"]
begin
titles(Object.new)
rescue ArgumentError => e
e.message # => "Object does not respond to #all"
end
ReadOnlyStore is a Store::Memory by is_a?, but Catalog#add crashes on it, so it is not substitutable. The
check that finds this class of bug is a contract test: one set of examples that every store (the real one, the
in-memory one, the adapter below) must pass (see
Testing with Minitest and
RSpec and Test Doubles). Inheritance is the usual way to violate
the principle; Equality, Hashing and Comparison
shows another, == and <=> implementations that are not symmetric.
Interface segregation
Do not force consumers to depend on methods they do not use. In Ruby you do this by naming small roles in documentation
and tests, and by accepting the narrowest object that works: a method that only reads takes anything with #all, a
callback takes anything with #call, a collection consumer takes anything with #each (and gets Enumerable for
free). The previous example already passes a Snapshot (a Struct with one reader) where a full store was never needed.
Ruby does not check roles at runtime unless you ask. Two ways to ask:
-
respond_to?at the boundary, astitlesabove does. Use it for a clear error at the edge of a public API, not inside every method, and prefer letting a missing method raiseNoMethodErrorwhen the failure is already informative. Remember thatrespond_to?is false for private methods, and for objects that implementmethod_missingit is only true when they also implementrespond_to_missing?. -
RBS interfaces, a static contract for tools such as TypeProf, Steep and Sorbet-compatible checkers (the names of RBS interfaces start with an underscore):
module Shelf
# Narrow roles: a consumer names only the methods it calls.
interface _Reader
def all: () -> Array[Book]
end
interface _Writer
def add: (Book book) -> untyped
end
interface _Store
include _Reader
include _Writer
end
class Reporter
def initialize: (_Reader source) -> void
def titles: () -> Array[String]
end
end
The _Reader and _Writer roles are separate; _Store composes them for the few consumers that need both. Reporter
declares that it needs only _Reader, so Snapshot, Store::Memory and a remote adapter are all acceptable arguments.
The signature was parsed with rbs parse and validated with rbs validate against the shelf signatures. See
Type Checking with RBS, TypeProf, Steep and Sorbet.
Comparable and Enumerable are the standard "role modules". Including them is the
declaration "I implement `<⇒`" or "I implement `each`", and the module supplies the rest of the interface.
Dependency inversion
High-level policy should not depend on low-level detail; both should depend on an abstraction. The Ruby idiom is to
receive collaborators through the constructor with a default that keeps the common case short. Catalog already
does this with store: Shelf.config.store. The same shape works for the clock and the notifier of a lending desk:
# frozen_string_literal: true
require "shelf"
module Shelf
# Dependency inversion: the service depends on two roles (#add_book / #call),
# receives them through keyword arguments and has sensible defaults.
class LendingDesk
def initialize(catalog: Catalog.new, clock: -> { Time.now }, notifier: ->(message) { warn(message) })
@catalog = catalog
@clock = clock
@notifier = notifier
end
def lend(book)
@catalog.add(book)
@notifier.call("#{book.title} lent at #{@clock.call.strftime('%H:%M')}")
end
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
# A test or a script substitutes fakes without any mocking library.
sent = []
desk = Shelf::LendingDesk.new(
catalog: Shelf::Catalog.new(store: Shelf::Store::Memory.new),
clock: -> { Time.new(2026, 10, 18, 9, 30) },
notifier: sent.method(:push)
)
desk.lend(book)
sent.first # => "The Dispossessed lent at 09:30"
Three details matter. First, the defaults are evaluated at each call (a default of Catalog.new builds a new catalog
for every desk, not one shared object), unlike a constant. Second, lambdas and Method objects (sent.method(:push))
are legitimate collaborators, so the "interface" for a notifier is simply #call. Third, the fakes in the example are
ordinary Ruby: for many tests a lambda and an array replace a mocking library, and the code under test stays unaware of
tests, which is what the principle is for. A clock passed in replaces test-only stubbing of Time.now (see
RSpec and Test Doubles for when stubbing is still the right tool).
Shelf.config is a controlled global used only for defaults; Application Architecture in Plain Ruby
covers global, thread-local and fiber-local state.
Design patterns
The Gang of Four catalogue (creational, structural and behavioural patterns) describes solutions to recurring design problems. In Ruby several are built into the language, so the "pattern" is often a single method call; the rest are short classes. Use the table to find the Ruby form before writing a class hierarchy.
| Pattern | Idiomatic Ruby | Section |
|---|---|---|
Factory, Abstract Factory |
A class method or module function returning an object; a |
|
Strategy |
A block, a lambda, a |
|
Observer |
A list of callables, or the bundled |
|
Decorator |
|
|
Adapter |
A small class translating one vocabulary to the expected role |
|
Facade |
One class (or module) exposing a short API over several objects |
|
Command |
An object with |
|
Iterator |
|
|
Template Method |
A superclass method that calls hook methods |
|
Singleton |
The |
|
Prototype |
|
Not covered |
Proxy |
|
Not covered |
Builder |
Keyword arguments, |
Not covered |
Factory
A factory decouples what is built from which class is built. Ruby gives you several levels:
-
newitself is already a factory method on the class object, andData.defineandStruct.neware factories of classes. -
A named class method or module function validates input and chooses the class (
Books.from_hashbelow). -
A registry maps a key to a class so that plugins add kinds without editing the factory (
Stores). -
Kernel conversion functions (
Integer("42"),Array(nil),String(x)) are factories that follow a naming convention: a method named like the class.
# frozen_string_literal: true
require "shelf"
module Shelf
# A factory method: validates and normalises raw input before building a Book.
module Books
def self.from_hash(row)
Book.new(isbn: Isbn.new(row.fetch(:isbn)), title: row.fetch(:title), author: Author.new(row.fetch(:author)))
end
end
# A registry-based factory: new store kinds plug in without editing build.
module Stores
@registry = { memory: Store::Memory }
def self.register(kind, klass) = @registry[kind] = klass
def self.build(kind, ...) = @registry.fetch(kind).new(...)
end
end
book = Shelf::Books.from_hash(isbn: "978-0-306-40615-7", title: "The Dispossessed", author: "Ursula K. Le Guin")
book.to_s # => "The Dispossessed by Ursula K. Le Guin"
Shelf::Stores.build(:memory).class # => Shelf::Store::Memory
begin
Shelf::Stores.build(:sqlite)
rescue KeyError => e
e.message # => "key not found: :sqlite"
end
Shelf::Stores.register(:sqlite, Class.new(Shelf::Store::Memory))
Shelf::Stores.build(:sqlite).all # => []
begin
Shelf::Books.from_hash(title: "No ISBN", author: "Anonymous")
rescue KeyError => e
e.message # => "key not found: :isbn"
end
Hash#fetch turns a missing key into a KeyError with the key in the message, which is better than nil flowing
into new; this is the documented behaviour, not an accident. build(kind, ...) uses argument forwarding, written
(...), so the factory does not need to know the constructor’s parameters (see
Calling Methods and Argument Forwarding).
@registry is a module-level instance variable, so the registration is global state: register at load time, not inside
request handling. Never build the class name from user input with constantize-style lookups
(Object.const_get(params[:kind])): a registry makes the allowed set explicit and is safe (see
Security).
Strategy
A strategy is an interchangeable algorithm. In Ruby the strategy is a block when it is used once, a lambda or Method
when it is stored, and an object with #call when it has state or a name.
# frozen_string_literal: true
require "shelf"
module Shelf
# The strategy is anything that responds to #call(days_late): a lambda,
# a Method object, a Symbol#to_proc or a small class.
class FineCalculator
def initialize(strategy = Fines.method(:for), &block)
@strategy = block || strategy
end
def fine_for(days_late) = @strategy.call(days_late)
end
module Fines
CENTS_PER_DAY = 25
def self.for(days_late) = days_late.clamp(0, 40) * CENTS_PER_DAY
end
class FlatFine
def initialize(cents) = @cents = cents
def call(days_late) = days_late.positive? ? @cents : 0
end
end
Shelf::FineCalculator.new.fine_for(4) # => 100
Shelf::FineCalculator.new { |days| days * 10 }.fine_for(4) # => 40
Shelf::FineCalculator.new(Shelf::FlatFine.new(500)).fine_for(4) # => 500
Shelf::FineCalculator.new(Shelf::FlatFine.new(500)).fine_for(0) # => 0
# Callables compose with >> and <<.
cap = ->(cents) { [cents, 300].min }
capped = Shelf::Fines.method(:for) >> cap
capped.call(30) # => 300
Shelf::FineCalculator.new(capped).fine_for(2) # => 50
# Built-in strategies: sort_by and max_by take the strategy as a block.
words = %w[Dune Emma Ubik Kindred]
words.sort_by(&:length) # => ["Dune", "Emma", "Ubik", "Kindred"]
words.max_by(&:length) # => "Kindred"
Method#>> (and Proc#>>, <<) compose callables, so a strategy can be wrapped by a cap, a rounding step or a
logging step without a class. A class-based strategy turns into a block with &: &FlatFine.new(500) would
need to_proc, which is why FineCalculator accepts either a positional callable or a block. sort_by, max_by,
group_by and sum are the standard library’s strategy-taking methods (see
Enumerable). Prefer it (Ruby 3.4) or _1 for one-argument blocks
that read well in one line, and name the parameter when the block is longer.
The FinePolicy role has no class in the code: it is the contract "responds to call with the number of days late". The
dotted arrows mean "plays the role", which in Ruby is a matter of responding to the message, not of inheriting.
Observer
Observer notifies dependants when something changes. The simplest Ruby version is a list of callables, as in
WatchedCatalog. The observer library provides the classic Observable mixin (add_observer, changed,
notify_observers) with observers that implement #update.
# frozen_string_literal: true
require "observer"
require "shelf"
module Shelf
# 1. A hand-rolled observer: listeners are blocks or anything that responds to #call.
class WatchedCatalog < Catalog
def initialize(...)
super
@listeners = []
end
def subscribe(listener = nil, &block)
@listeners << (listener || block)
self
end
def add(book)
super
@listeners.each { |listener| listener.call(book) }
self
end
end
# 2. The `observer` gem (a bundled gem since Ruby 3.4): Observable plus #update.
class Notifier
include Observable
def publish(message)
changed
notify_observers(message)
end
end
Subscriber = Struct.new(:seen) do
def update(message) = seen << message
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
log = []
catalog = Shelf::WatchedCatalog.new(store: Shelf::Store::Memory.new)
catalog.subscribe { |added| log << "added #{added.title}" }
catalog.add(book)
log.first # => "added The Dispossessed"
notifier = Shelf::Notifier.new
subscriber = Shelf::Subscriber.new([])
notifier.add_observer(subscriber)
notifier.publish("overdue: The Dispossessed")
subscriber.seen # => ["overdue: The Dispossessed"]
notifier.count_observers # => 1
Source: observer gem (ruby/observer)
observer stopped being a default gem and became a bundled gem in Ruby 3.4 (see the
3.4.0 NEWS). Running a script with plain ruby still finds it;
under Bundler, which only loads the gems in the Gemfile.lock, you must add gem "observer" to the Gemfile or
the gemspec of your library, or require "observer" raises LoadError. Older code and books often hand-roll the pattern or do
not mention the gem at all. Notes for both forms:
-
Notification is synchronous and runs in the caller’s thread; a slow or raising listener affects the publisher. Rescue per listener when failures must be isolated (see Exceptions and Error Handling).
-
Observable#notify_observersdoes nothing unlesschangedwas called first; forgetting it is the usual bug. -
Listeners hold a reference to the publisher or the publisher to the listeners; unsubscribe (
delete_observer) in long-running programs to avoid leaks. -
Mutating the listener list while notifying is not safe across threads; guard it with a mutex (see Threads and the GVL).
Decorator
A decorator wraps an object to add behaviour while keeping its interface. SimpleDelegator forwards every message the
decorator does not define to the wrapped object, which is available as __getobj__; DelegateClass(Klass) generates
a delegator class for a known class; Module#prepend is the alternative when the decorated object is a class you
control (see Modules and Mixins).
# frozen_string_literal: true
require "delegate"
require "shelf"
module Shelf
# Decorator: SimpleDelegator forwards everything not defined here to the wrapped object.
class BookPresenter < SimpleDelegator
def headline = "#{title.upcase} (#{author.name})"
def to_s = "[#{__getobj__}]"
end
# Decorating a collaborator: same interface (#all, #add), extra behaviour.
class CountingStore < SimpleDelegator
attr_reader :adds
def initialize(store)
super
@adds = 0
end
def add(book)
@adds += 1
super
end
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
presenter = Shelf::BookPresenter.new(book)
presenter.headline # => "THE DISPOSSESSED (Ursula K. Le Guin)"
presenter.title # => "The Dispossessed"
presenter.to_s # => "[The Dispossessed by Ursula K. Le Guin]"
presenter.__getobj__.equal?(book) # => true
# Pitfalls: the decorator is not the object, so identity and class checks differ.
presenter.is_a?(Shelf::Book) # => false
[presenter == book, presenter.eql?(book), book == presenter] # => [true, true, false]
counting = Shelf::CountingStore.new(Shelf::Store::Memory.new)
catalog = Shelf::Catalog.new(store: counting)
catalog.add(book).add(book)
counting.adds # => 2
counting.all.size # => 2
The decorator keeps the interface of its target, so it may wrap a Book (a presenter) or a store (a counting
wrapper) and Catalog is none the wiser. The pitfalls are the identity checks: is_a? and class answer for the
decorator, == and eql? forward to the wrapped object but Book#== (a Data method) does not accept a delegator, so
equality is not symmetric. Compare with __getobj__ when you need to be sure, and avoid decorating objects that are
used as hash keys or compared by identity. Prefer Data or Struct values wrapped by a presenter to subclassing them
(see Struct, Data and Value Objects): a
subclass of a Data.define result creates a pointless intermediate class.
Adapter
An adapter translates an existing class, often from a library you cannot change, into the interface your code
expects. Catalog expects a store with #all and #add. The remote client below speaks a different vocabulary, and
RemoteStoreAdapter converts between the two. The same example shows a Facade.
# frozen_string_literal: true
require "shelf"
module Shelf
# An imaginary third-party client with its own vocabulary.
class RemoteCatalogClient
def initialize = @rows = {}
def put_row(isbn, row) = @rows[isbn] = row
def fetch_rows = @rows.values
end
# Adapter: translates the client to the store interface Catalog expects (#all, #add).
class RemoteStoreAdapter
def initialize(client) = @client = client
def all
@client.fetch_rows.map do |row|
Book.new(isbn: Isbn.new(row[:isbn]), title: row[:title], author: Author.new(row[:author]))
end
end
def add(book)
@client.put_row(book.isbn.digits, { isbn: book.isbn.digits, title: book.title, author: book.author.name })
self
end
end
# Facade: one small entry point over Catalog, Report and the store choice.
class Library
def initialize(store: Store::Memory.new)
@catalog = Catalog.new(store:)
end
def register(isbn:, title:, author:)
book = Book.new(isbn: Isbn.new(isbn), title:, author: Author.new(author))
@catalog.add(book)
book
end
def titles = @catalog.map(&:title).sort
end
end
client = Shelf::RemoteCatalogClient.new
library = Shelf::Library.new(store: Shelf::RemoteStoreAdapter.new(client))
library.register(isbn: "978-0-306-40615-7", title: "The Dispossessed", author: "Ursula K. Le Guin")
library.register(isbn: "978-0-14-143951-8", title: "Pride and Prejudice", author: "Jane Austen")
library.titles # => ["Pride and Prejudice", "The Dispossessed"]
client.fetch_rows.size # => 2
The adapter does the translation in two directions: rows become Book values (so Isbn validation runs) and books
become rows. It is also where plugin systems end up: each plugin is an adapter registered under a name, and the
registry in the Factory section selects it. For a wrapper that only renames methods, Forwardable or alias_method
is enough; when it has to convert data, write a class.
Facade
A facade offers a short API over a subsystem. Library above hides Catalog, Isbn, Author and the store choice
behind register and titles; callers pass strings, and a bad ISBN raises ArgumentError from Isbn. Keep a facade
thin: when it grows policy and branching it has become a service object (see
Application Architecture in Plain Ruby). In a gem, the
top-level Shelf module functions (Shelf.configure) are the facade of the whole library.
Command
A command is a request as an object: it can be queued, logged, retried or undone. A lambda is enough when none of that
is needed; a small class or Data with call and undo covers the rest.
# frozen_string_literal: true
require "shelf"
module Shelf
# Command: an object that knows how to do and undo one action.
AddBook = Data.define(:books, :book) do
def call = books << book
def undo = books.delete(book)
end
# Invoker: runs commands and keeps a history for undo.
class History
def initialize = @done = []
def run(command)
command.call
@done << command
self
end
def undo = @done.pop&.undo
def size = @done.size
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
books = []
history = Shelf::History.new
history.run(Shelf::AddBook.new(books, book))
books.size # => 1
history.size # => 1
history.undo.title # => "The Dispossessed"
books.size # => 0
history.undo # => nil
# A command can also be a plain lambda when no undo is needed.
queue = [-> { books << book }, -> { books.size }]
queue.map(&:call).last # => 1
Source: Ruby 4.0 documentation: Data
@done.pop&.undo uses the safe navigation operator so that undoing an empty history returns nil instead of raising.
The AddBook value stores the array it mutates, so the command is not a pure value; if you need real value semantics
make the command describe the change and let the invoker apply it. The "callable object" convention (call) is what
makes commands interchangeable with lambdas, Method objects and, in Rack-style code, whole applications (see
Blocks, Procs and Lambdas).
Iterator
The iterator pattern is built into the language. A class that defines each and does include Enumerable is a
collection: map, select, min_by, sort_by, each_slice, lazy and the rest come with it. Calling each without a
block should return an Enumerator (return enum_for(:each) unless block_given?, as Catalog#each does), which gives
external iteration with next and peek.
# frozen_string_literal: true
require "shelf"
authors = ["Ursula K. Le Guin", "Jane Austen", "Octavia E. Butler"].map { |name| Shelf::Author.new(name) }
isbns = %w[978-0-306-40615-7 978-0-14-143951-8 978-0-8070-8310-9].map { |digits| Shelf::Isbn.new(digits) }
titles = ["The Dispossessed", "Pride and Prejudice", "Kindred"]
catalog = Shelf::Catalog.new(store: Shelf::Store::Memory.new)
titles.zip(isbns, authors) { |title, isbn, author| catalog.add(Shelf::Book.new(isbn:, title:, author:)) }
# Catalog defines only #each and includes Enumerable: all of these come for free.
catalog.map(&:title) # => ["The Dispossessed", "Pride and Prejudice", "Kindred"]
catalog.min_by { |book| book.title.length }.title # => "Kindred"
catalog.sort_by(&:title).map(&:title) # => ["Kindred", "Pride and Prejudice", "The Dispossessed"]
catalog.include?(catalog.first) # => true
catalog.each_slice(2).map { |pair| pair.map(&:title) } # => [["The Dispossessed", "Pride and Prejudice"], ["Kindred"]]
# Without a block, #each returns an Enumerator: external iteration with #next.
cursor = catalog.each
cursor.next.title # => "The Dispossessed"
cursor.next.title # => "Pride and Prejudice"
cursor.peek.title # => "Kindred"
cursor.next.title # => "Kindred"
begin
cursor.next
rescue StopIteration => e
e.message # => "iteration reached an end"
end
# Enumerator.new builds an iterator without defining a class.
isbn_digits = Enumerator.new { |yielder| catalog.each { |book| yielder << book.isbn.digits } }
isbn_digits.lazy.select { |digits| digits.start_with?("97803") }.first(1) # => ["9780306406157"]
Catalog#each yields from a snapshot taken under the mutex, so a block that calls add cannot deadlock or iterate a
changing array. Enumerable methods that need ordering (sort, min, max) also need elements that implement <=>,
which is why sort_by with a key is the usual choice. For big or infinite sources use lazy (see
Enumerators and Lazy Evaluation and
Enumerable).
Template Method and delegation
Template Method fixes an algorithm in a superclass and lets subclasses fill hooks. Ruby code usually prefers composition
(pass the hook as a block or object), but the pattern is idiomatic for small families. The abstract hook should raise
NoMethodError or a custom error. NotImplementedError is the common choice in older code, including both books this
section draws on, but it is a ScriptError, not a StandardError, so a bare rescue does not catch it, and the
documentation reserves it for features missing on the current platform.
Forwardable is delegation without a wrapper object: def_delegators defines methods that call a member. It is the standard-library
counterpart of the class-level delegate macro found in Rails; use it when the delegating class is not itself a
decorator of the target.
# frozen_string_literal: true
require "forwardable"
require "shelf"
module Shelf
# Template method: the base class fixes the algorithm, subclasses fill the hook.
# The hook raises NoMethodError, not NotImplementedError (a ScriptError that a bare
# rescue does not catch and that the docs reserve for missing platform features).
class Exporter
def export(books) = [header, *books.map { |book| row(book) }].join("\n")
def header = "title"
def row(_book) = raise(NoMethodError, "#{self.class} must implement #row")
end
class TitleExporter < Exporter
def row(book) = book.title
end
# Forwardable is delegation without a wrapper class (the standard-library
# counterpart of Rails' class-level delegate).
class Bookcase
extend Forwardable
def_delegators :@books, :size, :first, :each
def_delegator :@books, :push, :put
include Enumerable
def initialize = @books = []
end
end
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
Shelf::TitleExporter.new.export([book]) # => "title\nThe Dispossessed"
begin
Shelf::Exporter.new.export([book])
rescue NoMethodError => e
e.message # => "Shelf::Exporter must implement #row"
end
bookcase = Shelf::Bookcase.new
bookcase.put(book)
bookcase.size # => 1
bookcase.map(&:title) # => ["The Dispossessed"]
Singleton
The textbook Singleton is rarely needed: a module with module-level state (Shelf.config) is already one instance. When a
class genuinely needs a single instance, include Singleton. The frequently seen @instance ||= new is not thread-safe
(two threads can create two instances) and leaves new, dup and clone public.
# frozen_string_literal: true
require "singleton"
require "shelf"
module Shelf
# The Singleton module makes .new private, creates the instance thread-safely
# on first use and handles dup/clone; `@instance ||= new` does none of that.
class Clock
include Singleton
def now = Time.new(2026, 10, 18, 9, 30)
end
end
Shelf::Clock.instance.equal?(Shelf::Clock.instance) # => true
Shelf::Clock.instance.now.year # => 2026
begin
Shelf::Clock.new
rescue NoMethodError => e
e.message # => "private method 'new' called for class Shelf::Clock"
end
begin
Shelf::Clock.instance.dup
rescue TypeError => e
e.message # => "can't dup instance of singleton Shelf::Clock"
end
Global mutable singletons make tests order-dependent. Prefer passing the object (Dependency inversion above), and
reserve Singleton for things that are truly process-wide, such as a clock or a registry.
Choosing between pattern and plain Ruby
-
If the variation is one method, take a block or a callable; do not create a class hierarchy.
-
If the variation carries state or needs a name, make a small class with the same method name as the role.
-
If two classes share behaviour but not identity, use a module; if they share identity, inheritance (see Classes and Objects).
-
If you reach for metaprogramming to express a pattern (registries via
inherited, DSLs), check that the plain version was not enough first (see Hooks, eval and DSLs). Hooks must callsuper. -
Keep RuboCop’s design cops in mind as a smell detector, not a rule book:
Metrics/ClassLength,Metrics/AbcSizeandStyle/OneClassPerFile(enabled byNewCopsin RuboCop 1.92) point at the same problems as SRP (see Style, Linting and Static Analysis).
Where older books and code are outdated
This page draws on two recent books (a Ruby clean-code guide and a Rails architecture book) for the concepts only. Where their idioms predate Ruby 4.0 or are weak, the page uses the current approach:
| Older idiom | Current approach |
|---|---|
Abstract methods raising |
Raise |
`@instance |
|
= new` singletons |
The |
Hand-rolled observers, with |
|
|
|
|
|
The pre-3.4 |
|
Class-level |
|
|
|
|
Inject a clock (a callable) and use |
SimpleDelegator and the other examples above still use Data, a pattern-matching Loan and Enumerable, which
the older books present with Struct, case on class and hand-written loops.
Common pitfalls
| Mistake | Result | Fix |
|---|---|---|
Subclass that raises or ignores arguments of an inherited method |
Callers crash after substituting the subclass (Liskov) |
Compose, or split the role |
A |
Each new variant edits many places |
Pass an object that responds to the message |
Default argument as a shared mutable constant |
State leaks between instances |
Build the default in the parameter ( |
|
Noise, and false negatives with |
Check at the boundary, rely on |
|
|
Unwrap with |
|
Nothing is delivered |
Call |
Requiring |
|
Add |
Registry filled by |
Other hooks in the chain are skipped |
Always call |
References
-
Comparable, Enumerable, Enumerator and Data
-
Delegator and SimpleDelegator (
DelegateClassis documented on the Delegator page), Forwardable and Singleton -
observer gem (bundled since Ruby 3.4; 3.4.0 NEWS)
-
Proc, Method (
>>,<<,to_proc) and Object (respond_to?,public_send,method) -
Ruby 4.0 documentation: Calling Methods (argument forwarding)
-
RBS syntax guide (interfaces)
-
Carleton DiLeo, Clean Ruby: A Guide to Crafting Better Code for Rubyists, 2nd ed., Apress, 2026 — publisher page on SpringerLink (chapters 2 and 13, consulted for concepts only)
-
Vladimir Dementyev, Layered Design for Ruby on Rails Applications, 2nd ed., Packt Publishing, 2025 — publisher page at Packt (chapters 3, 8 and 9, consulted for concepts only)