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/elsif chain is a clamp; a manual accumulator loop is sum, to_h or each_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 when

A case kind that grows a branch for every new variant

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 (#all, #call, #each)

Passing a large object where one method is used, or respond_to? checks everywhere

Dependency inversion

Constructor injection with a default keyword argument; blocks and lambdas for behaviour

Store::Memory.new hard-coded in the middle of a method

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.

  • super is 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, as titles above does. Use it for a clear error at the edge of a public API, not inside every method, and prefer letting a missing method raise NoMethodError when the failure is already informative. Remember that respond_to? is false for private methods, and for objects that implement method_missing it is only true when they also implement respond_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 Hash registry; Class.new; conversion functions such as Integer()

Factory

Strategy

A block, a lambda, a Method object or any object with #call

Strategy

Observer

A list of callables, or the bundled observer gem

Observer

Decorator

SimpleDelegator, DelegateClass, or Module#prepend

Decorator

Adapter

A small class translating one vocabulary to the expected role

Adapter

Facade

One class (or module) exposing a short API over several objects

Facade

Command

An object with #call (and #undo); a lambda when undo is not needed

Command

Iterator

each plus include Enumerable; Enumerator for external iteration

Iterator

Template Method

A superclass method that calls hook methods

Template Method and delegation

Singleton

The Singleton module, or just a module with state

Singleton

Prototype

dup and clone (see Object Lifecycle, Freezing and Copying)

Not covered

Proxy

SimpleDelegator or method_missing with respond_to_missing? (see Dynamic Dispatch and Method Definition)

Not covered

Builder

Keyword arguments, Data.define with a block, and DSL blocks (see Hooks, eval and DSLs)

Not covered

Factory

A factory decouples what is built from which class is built. Ruby gives you several levels:

  1. new itself is already a factory method on the class object, and Data.define and Struct.new are factories of classes.

  2. A named class method or module function validates input and chooses the class (Books.from_hash below).

  3. A registry maps a key to a class so that plugins add kinds without editing the factory (Stores).

  4. 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.

classDiagram direction LR class FineCalculator { +fine_for(days_late) } class FinePolicy { <<role>> +call(days_late) } class FlatFine { +call(days_late) } class FinesFor["Fines.method(:for)"] { +call(days_late) } class LambdaOrBlock["lambda or block"] { +call(days_late) } FineCalculator --> FinePolicy : strategy FinePolicy <|.. FlatFine FinePolicy <|.. FinesFor FinePolicy <|.. LambdaOrBlock

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

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_observers does nothing unless changed was 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.

classDiagram direction LR class Delegator class SimpleDelegator class BookPresenter { +headline() +to_s() } class Book { +isbn +title +author } Delegator <|-- SimpleDelegator SimpleDelegator <|-- BookPresenter BookPresenter o-- Book : wraps via __getobj__

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.

classDiagram direction LR class Catalog { +add(book) +each() } class StoreRole["store role"] { <<role>> +all() +add(book) } class MemoryStore["Store::Memory"] { +all() +add(book) } class RemoteStoreAdapter { +all() +add(book) } class RemoteCatalogClient { +put_row(isbn, row) +fetch_rows() } Catalog --> StoreRole : store StoreRole <|.. MemoryStore StoreRole <|.. RemoteStoreAdapter RemoteStoreAdapter --> RemoteCatalogClient : translates

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

@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 call super.

  • Keep RuboCop’s design cops in mind as a smell detector, not a rule book: Metrics/ClassLength, Metrics/AbcSize and Style/OneClassPerFile (enabled by NewCops in 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 NotImplementedError

Raise NoMethodError or a custom StandardError subclass; NotImplementedError is a ScriptError for missing platform features

`@instance

= new` singletons

The Singleton module (thread-safe, new private), or a module with state

Hand-rolled observers, with observer assumed to be standard library

observer is a bundled gem since 3.4: add it to the Gemfile

class Money < Struct.new(...) and class Point < Data.define(...)

Money = Data.define(...) do ... end; keyword_init: true is not needed for keyword construction since 3.2

_1 for implicit block parameters only

it (3.4) for single-parameter blocks

The pre-3.4 inspect format ({:title=>"Dune"}) and the old error message with a backtick before the method name and nil:NilClass

{title: "Dune"} (3.4 inspect) and undefined method 'x' for nil

Class-level delegate (an Active Support macro)

Forwardable (def_delegators) in plain Ruby

send to call collaborators

public_send, which respects visibility, or a direct call

DateTime or Time.now buried in logic

Inject a clock (a callable) and use Time

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 case/when on a type name in every consumer

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 (store: Store::Memory.new) so it is created per call

respond_to? on every call

Noise, and false negatives with method_missing

Check at the boundary, rely on NoMethodError elsewhere

SimpleDelegator around an object used as a hash key

hash and eql? forward, == is not symmetric

Unwrap with __getobj__

notify_observers without changed

Nothing is delivered

Call changed first

Requiring observer under Bundler without a Gemfile entry

LoadError

Add gem "observer"

Registry filled by inherited without super

Other hooks in the chain are skipped

Always call super

References