Application Architecture in Plain Ruby

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. It answers a question the language itself does not: once a program outgrows one file, where does each piece of code go, and who may call whom? Ruby has no interface, no package and no dependency-injection container in its core, so architecture here is a set of conventions carried by ordinary objects: immutable values, objects with one public call method, objects handed to constructors, and a handful of rules about what may require what. The page builds those conventions step by step on the shelf library catalogue used throughout this section, without any framework. Rails is out of scope for this section (see From plain Ruby to Rails for how the ideas map to it).

The language features the patterns rest on have their own pages: Struct, Data and Value Objects and Equality, Hashing and Comparison for value objects, Pattern Matching for result objects, Methods and Parameters and Calling Methods and Argument Forwarding for keyword defaults and ... forwarding, Modules and Mixins for namespaces and mixins, and Threads and the GVL and Fibers and the Fiber Scheduler for the concurrency that makes global state dangerous. The design principles behind the structure are in SOLID and Design Patterns and Idioms, Style and Clean Code.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 without warnings, and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The values in # => comments are the real results. The classes are defined in the files shown. Where a later listing needs an earlier one it loads it with require, for example require "shelf/lend_book": for those runs the earlier definitions were saved as files under a lib/shelf/ directory. A few classes (Shelf::Store::Memory, Shelf::Catalog, Shelf::Isbn) come from the running gem (see the shelf gem source).

What "architecture" means without a framework

Start small, split when it hurts

A catalogue script of fifty lines needs no layers, and adding them early is the commonest architectural mistake in Ruby (YAGNI: you are not going to need it). Ruby makes the opposite move cheap: an object extracted from a method, a hash replaced with a Data value, a constant replaced with a constructor argument. Split code when one of these hurts:

  • a change in one place forces edits in unrelated places (the rule that decides who may borrow a book is tangled with the code that prints a receipt);

  • a test needs a database, the network or the clock to check a rule that has nothing to do with them;

  • the same rule is written twice in two entry points (a command-line script and an HTTP handler);

  • a class has grown several unrelated reasons to change.

The patterns below are the standard answers, listed with the symptom each one cures. None of them is mandatory.

The four layers

The shelf gem is arranged in four layers, drawn in the figure. A layer is not a Ruby construct; it is a convention about which classes may mention which others, and the only enforcement is review, tests and tooling.

The layers of the shelf gem: presentation calls application, application uses domain, infrastructure implements the ports, and a composition root wires them
Layer Responsibility Ruby constructs in the shelf gem

Domain

The vocabulary and the rules of the problem, free of I/O and of any framework.

Data.define value objects (Isbn, Author, Book, Fine), Comparable, small classes with invariants (Loan).

Application

One class per use case: what the system does, in terms of the domain, with collaborators passed in.

Callable service objects (LendBook), query objects, filter objects, policy objects, result objects.

Infrastructure

Everything that touches the outside: storage, files, the network, the clock, ENV.

Store adapters (Store::Memory, Store::Legacy), the loan repository, Settings.from_env.

Presentation

Translating between the outside world’s shapes and the application’s method calls.

The exe/shelf script, presenters, serializers, parsers and form objects.

The dependency rule gives the arrows in the figure: source code in an inner layer never names a class from an outer one. The domain requires nothing from the gem except other domain files. The application layer knows its collaborators only as objects that answer a few messages (the ports: all, add, lent?), never as Store::Legacy. The outer layers know the inner ones. Something has to build the concrete objects and connect them: the composition root (see The composition root and test seams), a small class or script at the edge.

What Ruby gives you in place of interfaces

Other languages draw these boundaries with interface, protocol or trait declarations, access modifiers on packages, and a dependency-injection container. Ruby uses four lighter tools instead:

  • Duck typing. A port is a set of messages, documented in prose, in an abstract base class, or in an RBS interface (see Adapters, ports and plugins). Nothing is declared at the call site.

  • Namespaces. A module such as Shelf::Store groups related classes and prevents name clashes; it has no behaviour of its own. Keep the module tree shallow and let it mirror the layers or the features, not both.

  • Visibility. private and private_constant keep helpers inside a class (see Visibility and Encapsulation). They are advisory, which is why a clear public surface matters more than hiding.

  • Constructor arguments. Collaborators arrive as keyword arguments with sensible defaults (see Dependency injection with default keyword arguments). Without a container, this is all the injection most programs need.

A possible directory layout keeps one constant per file and requires the files in dependency order from lib/shelf.rb (see Code Loading: require and autoload for require_relative, autoload and the Zeitwerk loader, a community gem that was not run for this page):

lib/
  shelf.rb                   # requires the rest, in dependency order
  shelf/
    version.rb
    isbn.rb  author.rb  book.rb  loan.rb  fine.rb       # domain
    lend_book.rb  books_matching.rb  lending_policy.rb   # application
    store/memory.rb  store/legacy.rb  loans/memory.rb    # infrastructure
    settings.rb                                          # infrastructure (ENV)
    book_presenter.rb  book_serializer.rb                # presentation
    application.rb                                       # composition root
exe/
  shelf                      # presentation entry point

Layering by directory is optional. What matters is that each file’s require lines respect the dependency rule: a quick way to audit it is to search the domain files for any other layer’s constants, which a short test can automate.

Objects and the roles they play

A useful vocabulary for the objects in a Ruby design, independent of layers:

Role What it is Example in the shelf gem

Value object

Defined by its content, immutable, compared with ==, cheap to create and to discard.

Isbn, Book, Fine, SearchFilter

Entity or service

Has an identity or a lifetime, or does work; compared by identity.

Catalog, Loan, LendBook

Collaborator

An object another object talks to, passed in rather than created inside.

The store: given to Catalog, the loans: given to LendBook

Delegate

A thin wrapper that adds one concern and forwards the rest.

Store::Counting, BookPresenter

Callable

An object whose one public job is call.

LendBook, Slug, a lambda

Classes and modules also divide naturally. A class makes objects and can be subclassed once; a module cannot make objects and is used in four ways: as a namespace (Shelf::Store), as a mixin that adds behaviour to several classes (Comparable, Enumerable), as a function bag whose methods are called on the module itself (Math, or module_function), and as an extension point (extend, prepend). Choose a class for something with state and identity, a module for a behaviour shared across unrelated classes, and prefer composition (hold a collaborator) over inheritance when the relationship is "has a" rather than "is a". The details are in Modules and Mixins.

The domain layer: value objects

A value object is the cheapest and most valuable building block: a small immutable object that carries a validated value and the operations that belong to it. Data.define (Ruby 3.2) builds it with keyword and positional construction, ==, eql?, hash, inspect and to_h, and freezes every instance. The Fine class below adds validation in initialize, ordering through Comparable, an arithmetic operator that returns a new value with with, and a formatted to_s.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A fine in cents: immutable, comparable, and usable as a Hash key.
  Fine = Data.define(:cents) do
    include Comparable

    def initialize(cents: 0)
      valid = cents.is_a?(Integer) && !cents.negative?
      raise ArgumentError, "cents must be a non-negative Integer, got #{cents.inspect}" unless valid

      super
    end

    def <=>(other) = other.is_a?(Fine) ? cents <=> other.cents : nil

    def +(other) = with(cents: cents + other.cents)

    def zero? = cents.zero?

    def to_s = format("%<euros>d.%<rest>02d EUR", euros: cents / 100, rest: cents % 100)
  end
end

a = Shelf::Fine.new(250)
b = Shelf::Fine.new(cents: 75)
total = a + b # => #<data Shelf::Fine cents=325>
total.to_s                                    # => "3.25 EUR"
[a, b].min                                    # => #<data Shelf::Fine cents=75>
a.between?(b, Shelf::Fine.new(1000))          # => true
Shelf::Fine.new                               # => #<data Shelf::Fine cents=0>
{ a => :open }[Shelf::Fine.new(250)]          # => :open
a.frozen?                                     # => true

begin
  Shelf::Fine.new(-1)
rescue ArgumentError => e
  e.message # => "cents must be a non-negative Integer, got -1"
end

begin
  a < 5
rescue ArgumentError => e
  e.message # => "comparison of Shelf::Fine with 5 failed"
end

begin
  a.instance_variable_set(:@cents, 1)
rescue FrozenError => e
  e.message # => "can't modify frozen Shelf::Fine: #<data Shelf::Fine cents=250>"
end

Source: Data and Comparable

Points worth knowing about this class:

  • Data converts positional arguments to keywords before calling initialize, so an overriding initialize always receives keywords. Calling super with no arguments forwards them unchanged. A default (cents: 0) makes the argument optional.

  • Equality and hash come from Data, so instances are interchangeable as Hash keys and as members of a Set. The Comparable mixin adds ordering, between?, clamp and the comparison operators from one <=> method. Return nil from <=> for an unrelated type, as above; that makes the operators raise ArgumentError rather than answering wrongly.

  • The instance is frozen, so a value can be shared freely between layers and threads. To "change" it, build a new one (with, or a method that returns with(...)).

  • Money in cents as an Integer avoids float rounding errors (see Numbers).

  • Subclassing the result of Data.define (class Fine < Data.define(:cents)) works but creates an extra anonymous class for no benefit; assign the result to a constant and pass a block, as here.

Wrapping primitives in value objects moves validation to the border: an Isbn that exists is valid, so no later method has to re-check it. This is the idea behind keeping the domain layer free of I/O: its objects can be created in a test with no setup at all. See Struct, Data and Value Objects for Struct, pattern matching on Data and the choice between them.

The application layer: callable service objects

A service object implements one use case: its constructor takes the collaborators and the inputs, and its single public method, conventionally call, does the work and returns the outcome. A one-line class method, def self.call(...) = new(...).call, is the usual shortcut. The ... forwards every positional and keyword argument and the block, and accepts a leading argument since Ruby 3.0; see Calling Methods and Argument Forwarding.

The outcome is a result object: a small value that says what happened, so callers branch on data instead of rescuing exceptions for expected outcomes. Data classes make good result types, and a case/in expression consumes them.

# frozen_string_literal: true

require "shelf"

module Shelf
  module Loans
    # Infrastructure: where lent ISBNs are remembered.
    class Memory
      def initialize = @lent = {}

      def lent?(isbn) = @lent.key?(isbn)

      def add(isbn, due_at) = @lent[isbn] = due_at

      def due_at(isbn) = @lent.fetch(isbn)
    end
  end

  # Application layer: one use case, one callable object.
  class LendBook
    Success = Data.define(:book, :due_at)
    Failure = Data.define(:reason)

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

    def initialize(catalog:, isbn:, loans: Loans::Memory.new, clock: -> { Time.now }, loan_days: Shelf.config.loan_days)
      @catalog = catalog
      @isbn = isbn
      @loans = loans
      @clock = clock
      @loan_days = loan_days
    end

    def call
      book = @catalog.find { |b| b.isbn == @isbn }
      return Failure.new(reason: :unknown_isbn) unless book
      return Failure.new(reason: :already_lent) if @loans.lent?(@isbn)

      due_at = @clock.call + (@loan_days * 86_400)
      @loans.add(@isbn, due_at)
      Success.new(book:, due_at:)
    end
  end
end

isbn = Shelf::Isbn.new("978-0-306-40615-7")
catalog = Shelf::Catalog.new.add(Shelf::Book.new(isbn, "The Dispossessed", Shelf::Author.new("Ursula K. Le Guin")))
loans = Shelf::Loans::Memory.new
clock = -> { Time.utc(2026, 10, 18, 9, 0) }

def report(result)
  case result
  in Shelf::LendBook::Success(book:, due_at:)
    "lent #{book.title} until #{due_at.strftime('%Y-%m-%d')}"
  in Shelf::LendBook::Failure(reason: :already_lent)
    "sorry, already lent"
  in Shelf::LendBook::Failure(reason:)
    "failed: #{reason}"
  end
end

report(Shelf::LendBook.call(catalog:, isbn:, loans:, clock:)) # => "lent The Dispossessed until 2026-11-01"
report(Shelf::LendBook.call(catalog:, isbn:, loans:, clock:)) # => "sorry, already lent"
report(Shelf::LendBook.call(catalog:, isbn: Shelf::Isbn.new("978-1-4028-9462-6"), loans:)) # => "failed: unknown_isbn"

begin
  Shelf::LendBook.call(catalog:)
rescue ArgumentError => e
  e.message # => "missing keyword: :isbn"
end

What the example shows:

  • One public method. LendBook has call and nothing else public. Its instance variables are set in the constructor and never reassigned, so one instance describes one request and can be thrown away.

  • Failure as data. Success and Failure are Data classes. The caller matches with in Success(book:, due_at:), which uses deconstruct_keys (defined by Data since 3.2) and binds the named parts. The case expression above lists every result type. A case/in with no matching branch and no else raises NoMatchingPatternError, which is what you want when the result types are a closed set; add else when they are not.

  • Exceptions for the unexpected. A missing keyword is a programming error and raises ArgumentError. An unknown ISBN is an expected outcome and is returned. Keep the two apart: see Exceptions and Error Handling.

  • Time and storage are arguments. The clock is a lambda, loans is any object that answers lent? and add, and loan_days defaults to a value read from the global configuration at call time. Replacing any of them in a test needs no stubbing library.

  • The Loans::Memory class is infrastructure living in the same file for brevity; in a larger gem it would sit in loans/memory.rb.

Other shapes of callable work equally well and compose better, because Ruby treats anything that answers call like a lambda. A to_proc method makes an object usable with &, Proc#>> and Proc#<< chain callables left to right or right to left, and alias_method gives a second, more descriptive name to the same method.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A callable object: the one public method is #call, so it composes like a lambda.
  class Slug
    def self.call(...) = new.call(...)

    def call(title) = title.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")

    alias to_slug call

    def to_proc = method(:call).to_proc
  end
end

Shelf::Slug.call("The Dispossessed!") # => "the-dispossessed"
Shelf::Slug.new.call("A Wizard of Earthsea") # => "a-wizard-of-earthsea"
Shelf::Slug.new.to_slug("Dracula") # => "dracula"
%w[Dune Kindred].map(&Shelf::Slug.new) # => ["dune", "kindred"]

pipeline = :strip.to_proc >> Shelf::Slug.new >> ->(slug) { "/books/#{slug}" }
pipeline.call("  The Left Hand of Darkness ")     # => "/books/the-left-hand-of-darkness"
pipeline.lambda?                                  # => true

The .() form (slug.(title)) is shorthand for slug.call(title); RuboCop’s Style/LambdaCall prefers the explicit call. A chain of callables is the same idea as a Rack middleware stack: each link takes one value and returns the next, so the pipeline’s only requirement is the call convention. Use it when a use case is a straight sequence of transformations; use a service object with explicit steps when the flow has branches.

Why a service object and not a method on the model

Putting lend on Book or Catalog is tempting and is the right choice while the logic is one line. It stops being the right choice when the operation needs a second collaborator (a loan repository, a clock, a mailer), when it appears in more than one entry point, or when you want to test it without building a whole catalogue. A service object gives the operation a name, a place for its collaborators, and a return type; the domain objects stay small. The cost is one more class, which is why the extraction should follow the symptoms listed at the start of this page, not precede them.

Queries, filters, forms and policies

Four more small object types keep rules out of controllers, scripts and models. They share a shape: a constructor that takes collaborators, one public method that takes the input, and a return value that is either data or a decision.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Filter object: turns raw, untrusted parameters into a small immutable value.
  SearchFilter = Data.define(:title, :author) do
    def self.parse(params)
      new(title: clean(params[:title]), author: clean(params[:author]))
    end

    def self.clean(value) = value.to_s.strip.then { it.empty? ? nil : it.downcase }
    private_class_method :clean

    def keep?(book)
      (title.nil? || book.title.downcase.include?(title)) &&
        (author.nil? || book.author.name.downcase.include?(author))
    end
  end

  # Query object: one question asked of the catalogue, returning a lazy enumerator.
  class BooksMatching
    def initialize(catalog) = @catalog = catalog

    def call(filter) = @catalog.lazy.select { filter.keep?(it) }
  end

  # Form object: validates input and only then builds domain objects.
  class NewBookForm
    attr_reader :errors

    def initialize(isbn:, title:, author:)
      @isbn = isbn
      @title = title
      @author = author
      @errors = {}
    end

    def valid?
      @errors = {}
      @errors[:title] = "can't be blank" if @title.to_s.strip.empty?
      @errors[:author] = "can't be blank" if @author.to_s.strip.empty?
      @errors[:isbn] = "is not a valid ISBN-13" unless isbn
      @errors.empty?
    end

    def to_book
      raise Shelf::Error, "form is invalid: #{errors.keys.join(', ')}" unless valid?

      Book.new(isbn, @title.strip, Author.new(@author.strip))
    end

    private

    def isbn
      Isbn.new(@isbn)
    rescue ArgumentError
      nil
    end
  end

  # Policy object: a decision plus its reason, with no side effects.
  class LendingPolicy
    Decision = Data.define(:allowed, :reason)

    def initialize(max_loans: 3) = @max_loans = max_loans

    def call(fine_cents:, active_loans:)
      return Decision.new(false, "too many loans") if active_loans >= @max_loans

      case fine_cents
      when ...500 then Decision.new(true, nil)
      when 500...2000 then Decision.new(true, "pay your fines soon")
      else Decision.new(false, "fines of #{fine_cents} cents are due")
      end
    end
  end
end

catalog = Shelf::Catalog.new
[["978-0-306-40615-7", "The Dispossessed", "Ursula K. Le Guin"],
 ["978-1-4028-9462-6", "A Wizard of Earthsea", "Ursula K. Le Guin"],
 ["978-0-14-143951-8", "Dracula", "Bram Stoker"]].each do |isbn, title, author|
  catalog.add(Shelf::NewBookForm.new(isbn:, title:, author:).to_book)
end

filter = Shelf::SearchFilter.parse({ title: "  ", author: "LE GUIN " })
filter.to_h # => {title: nil, author: "le guin"}
Shelf::BooksMatching.new(catalog).call(filter).map(&:title).to_a # => ["The Dispossessed", "A Wizard of Earthsea"]

form = Shelf::NewBookForm.new(isbn: "978-0-306-40615-8", title: " ", author: "Ana")
form.valid? # => false
form.errors # => {title: "can't be blank", isbn: "is not a valid ISBN-13"}

policy = Shelf::LendingPolicy.new
policy.call(fine_cents: 100, active_loans: 1) # => #<data Shelf::LendingPolicy::Decision allowed=true, reason=nil>
policy.call(fine_cents: 750, active_loans: 1).reason # => "pay your fines soon"
policy.call(fine_cents: 5000, active_loans: 1).reason # => "fines of 5000 cents are due"
policy.call(fine_cents: 0, active_loans: 3).allowed # => false

Source: Ruby 4.0 documentation: Control Expressions (beginless and endless ranges in when) and Enumerator::Lazy

  • Filter object (SearchFilter). Turns untrusted, loosely shaped parameters (strings, blanks, mixed case) into a small immutable value in one place. Everything after parse receives clean data. The cleaning helper is a private_class_method, so the public surface is parse, the accessors and keep?.

  • Query object (BooksMatching). One question asked of the catalogue. It takes the source in the constructor and the filter in call, and returns a lazy enumerator, so the caller decides how much to read (see Enumerators and Lazy Evaluation). Keeping the query separate from Catalog stops the catalogue growing a method for every report anyone ever wants.

  • Form object (NewBookForm). Gathers raw input, validates it, collects error messages, and only then builds domain objects. The errors are a Hash keyed by field; to_book raises a Shelf::Error if called on an invalid form. The private isbn method turns the ArgumentError from Isbn.new into nil so the form can report it as a field error. Use a form object when the input shape differs from the domain shape or when several records are created together.

  • Policy object (LendingPolicy). A decision with a reason and no side effects. Because it takes plain numbers, it is trivially testable and can be reused by the command line and by a web handler. The case with a beginless range (when ...500) and a bounded one (when 500...2000) reads as a table of thresholds; ... excludes the end, and a value outside every range falls to else.

The decision type (Decision) is nested inside the policy so that its full name, LendingPolicy::Decision, says where it belongs. When several rules combine (fines, loan counts, membership state), keep one object per rule and compose them, rather than one method with a long chain of conditions.

Adapters, ports and plugins

A port is the set of messages the application needs from the outside world; an adapter is a class that provides them by wrapping a particular technology. In Ruby the port is usually informal. The shelf gem’s catalogue needs all and add from a store, and LendBook needs lent? and add from a loan repository. Any object that answers those works: Store::Memory, a vendor client wrapped in an adapter, or a ten-line fake in a test.

# frozen_string_literal: true

require "delegate"
require "shelf"

module Shelf
  module Store
    # The port: what the application layer expects of any store.
    class Base
      @registry = {}

      class << self
        attr_reader :registry

        # Every named subclass registers itself under its lower-cased class name.
        def inherited(subclass)
          super
          Base.registry[subclass.name.split("::").last.downcase.to_sym] = subclass if subclass.name
        end

        def build(name, ...)
          Base.registry.fetch(name) { raise Shelf::Error, "unknown store: #{name.inspect}" }.new(...)
        end
      end

      def all = raise(NoMethodError, "#{self.class} must implement #all")

      def add(_book) = raise(NoMethodError, "#{self.class} must implement #add")
    end

    # An adapter around a vendor client with a different vocabulary.
    class Legacy < Base
      def initialize(client)
        super()
        @client = client
      end

      def all = @client.fetch_rows.map { |isbn, title, author| Book.new(Isbn.new(isbn), title, Author.new(author)) }

      def add(book)
        @client.insert_row([book.isbn.digits, book.title, book.author.name])
        self
      end
    end

    # A decorator that adds behaviour to any store without changing its interface.
    class Counting < SimpleDelegator
      attr_reader :reads

      def initialize(store)
        super
        @reads = 0
      end

      def all
        @reads += 1
        super
      end
    end
  end
end

# A stand-in for a third-party client with its own vocabulary.
LegacyClient = Struct.new(:rows) do
  def fetch_rows = rows.dup

  def insert_row(row) = rows << row
end

book = Shelf::Book.new(Shelf::Isbn.new("978-0-306-40615-7"), "The Dispossessed", Shelf::Author.new("Ursula K. Le Guin"))

legacy = Shelf::Store::Base.build(:legacy, LegacyClient.new([]))
store = Shelf::Store::Counting.new(legacy)
catalog = Shelf::Catalog.new(store:).add(book)

catalog.map(&:to_s)  # => ["The Dispossessed by Ursula K. Le Guin"]
store.reads          # => 1
Shelf::Store::Base.registry.keys # => [:legacy]

begin
  Shelf::Store::Base.build(:redis)
rescue Shelf::Error => e
  e.message # => "unknown store: :redis"
end

begin
  Incomplete = Class.new(Shelf::Store::Base) # rubocop:disable Style/EmptyClassDefinition
  Incomplete.new.all
rescue NoMethodError => e
  e.message # => "Incomplete must implement #all"
end

Shelf::Store::Base.registry.keys # => [:legacy]

Four techniques appear in this listing:

  • An abstract base class that fails loudly. Store::Base documents the port by defining the methods and raising NoMethodError from each, with a message naming the missing method. The widely copied alternative, NotImplementedError, is a poor choice: it descends from ScriptError, not StandardError, so a plain rescue does not catch it, and it signals "this platform does not support the call" rather than "a subclass forgot to implement this". Use NoMethodError or a project error.

  • A self-registering plugin hook. Defining inherited on a base class lets every subclass register itself under a name, so adding an adapter means adding a file. Two rules apply: call super, so other inherited hooks (including Ruby’s own) still run, and remember that subclass.name is nil for an anonymous class created with Class.new, because the constant is assigned only after the hook runs; hence the if subclass.name guard. In the listing, Legacy registers as :legacy, while the anonymous subclass assigned to Incomplete at the end does not, so the registry still lists only :legacy.

  • An adapter (Store::Legacy) translates between vocabularies: the vendor client speaks in rows and fetch_rows, the application speaks in Book values and all. The translation lives in one class; the rest of the system never sees a row. When wrapping a library whose calls you want to keep out of your code, call it from exactly one adapter and fake it in tests.

  • A delegate that adds a concern (Store::Counting) subclasses SimpleDelegator, forwards everything to the object it wraps, and overrides one method, calling super to reach the wrapped object. SimpleDelegator comes from the delegate default gem, so require "delegate" is needed.

The registry here is a class-level instance variable on the base class, read with a class << self accessor, which is the form to prefer over a class variable (@@registry) because subclasses do not share it by accident. The factory method build(name, ...) forwards the remaining arguments to the chosen class’s new.

A port can also be written down for tools. Type checkers read RBS, whose interface declarations (names start with an underscore) describe a structural type: any class with matching methods satisfies it, with no include. The shelf gem’s Catalog#initialize takes store: untyped in its signature; an interface tightens that:

module Shelf
  # What the application layer expects of any store.
  interface _Store
    def all: () -> Array[Book]
    def add: (Book book) -> self
  end

  class Catalog
    include Enumerable[Book]

    def initialize: (?store: _Store) -> void
  end
end

This signature was parsed with rbs parse; type-checking against it is the subject of Type Checking with RBS, TypeProf, Steep and Sorbet. When you do not use a checker, a test that runs the same examples against every adapter (a shared contract) gives the same safety.

Dependency injection with default keyword arguments

Dependency injection means that an object receives its collaborators instead of creating them. In Ruby the whole mechanism fits in a constructor signature: required keyword arguments for things the object cannot guess, and keyword arguments with default values for collaborators that have a sensible standard choice.

The signature of LendBook above is initialize(catalog:, isbn:, loans: Loans::Memory.new, clock: -> { Time.now }, loan_days: Shelf.config.loan_days). One definition supports three uses: a quick script relies on the defaults, the application passes the shared collaborators, and a test passes fakes.

# frozen_string_literal: true

require "shelf"
require "shelf/lend_book"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn, "The Dispossessed", Shelf::Author.new("Ursula K. Le Guin"))
catalog = Shelf::Catalog.new.add(book)
shared = Shelf::Loans::Memory.new

# 1. A quick script relies on the defaults: a fresh repository and the system clock.
Shelf::LendBook.call(catalog:, isbn:).class # => Shelf::LendBook::Success
Shelf::LendBook.call(catalog:, isbn:).class # => Shelf::LendBook::Success

# 2. The application passes the one shared repository: the second call is refused.
Shelf::LendBook.call(catalog:, isbn:, loans: shared).class # => Shelf::LendBook::Success
Shelf::LendBook.call(catalog:, isbn:, loans: shared).class # => Shelf::LendBook::Failure

# 3. A test replaces the clock and the number of days.
result = Shelf::LendBook.call(catalog:, isbn:, clock: -> { Time.utc(2026, 1, 1) }, loan_days: 1)
result.due_at.iso8601 # => "2026-01-02T00:00:00Z"

Source: Ruby 4.0 documentation: Methods (keyword arguments)

Rules for doing it well:

  • Defaults are evaluated on every call, so Loans::Memory.new creates a fresh repository each time. That is correct for a throwaway default and wrong for shared state: a default that must be the same object everywhere should come from a single place (the composition root), not from a default expression.

  • Inject the clock, the random generator, the logger and the environment as well. A lambda -> { Time.now } is enough; the same applies to ENV, which can be replaced by any object that responds to fetch (a Hash).

  • Depend on messages, not on classes. Do not check is_a? on a collaborator; if you must validate, use respond_to?(:lent?) at the border, or let the first call fail with NoMethodError, which names the missing method.

  • Avoid a default that reaches into global state unless the global is itself configuration read once. Here Shelf.config.loan_days is a plain number, which is harmless; a default that reads a mutable singleton hides a dependency from the signature.

  • Do not build a container first. A hash of lambdas or a few lines in the composition root are enough for most programs. Reach for a gem only when the object graph is large enough that wiring it by hand obscures the program.

Ruby also lets a class accept a collaborator through a block or a class-level setting, but a constructor argument is the most visible and the easiest to test. For the SOLID principle behind it (depend on abstractions) see SOLID and Design Patterns.

Presenters, decorators and serializers

The outermost layer shapes data for people and for other programs. Plain classes are enough.

# frozen_string_literal: true

require "delegate"
require "json"
require "shelf"

module Shelf
  # Open presenter: a decorator that forwards everything it does not override.
  class BookPresenter < SimpleDelegator
    def label = "#{title} (#{author.name})"

    def isbn_with_hyphens = isbn.digits.sub(/\A(\d{3})(\d)(\d{3})(\d{5})(\d)\z/, '\1-\2-\3-\4-\5')
  end

  # Closed presenter: exposes exactly what the view needs and nothing else.
  class BookCard
    def initialize(book) = @book = book

    def title = @book.title

    def author = @book.author.name
  end

  # Serializer: domain object in, plain Hash out. No JSON dependency here.
  class BookSerializer
    def initialize(book) = @book = book

    def as_json = { isbn: @book.isbn.digits, title: @book.title, author: @book.author.name }
  end

  # Boundary parser: untrusted JSON in, domain object out.
  module BookParser
    def self.call(json)
      case JSON.parse(json, symbolize_names: true)
      in { isbn: String => isbn, title: String => title, author: { name: String => name } }
        Book.new(Isbn.new(isbn), title, Author.new(name))
      end
    end
  end
end

book = Shelf::Book.new(Shelf::Isbn.new("978-0-306-40615-7"), "The Dispossessed", Shelf::Author.new("Ursula K. Le Guin"))

presenter = Shelf::BookPresenter.new(book)
presenter.label                # => "The Dispossessed (Ursula K. Le Guin)"
presenter.isbn_with_hyphens    # => "978-0-306-40615-7"
presenter.title                # => "The Dispossessed"
presenter.eql?(book)           # => true
book.eql?(presenter)           # => false
presenter.is_a?(Shelf::Book)   # => false
presenter.__getobj__.equal?(book) # => true

card = Shelf::BookCard.new(book)
card.respond_to?(:isbn) # => false

serializer = Shelf::BookSerializer.new(book)
serializer.as_json # => {isbn: "9780306406157", title: "The Dispossessed", author: "Ursula K. Le Guin"}
JSON.generate(serializer.as_json).start_with?('{"isbn":"9780306406157"') # => true

wire = '{"isbn":"978-0-306-40615-7","title":"The Dispossessed","author":{"name":"Ursula K. Le Guin"}}'
Shelf::BookParser.call(wire).eql?(book) # => true

begin
  Shelf::BookParser.call('{"title":"No ISBN"}')
rescue NoMatchingPatternKeyError => e
  e.message # => "{title: \"No ISBN\"}: key not found: :isbn"
end
  • Open presenter (BookPresenter, a SimpleDelegator): wraps a domain object, adds view-specific methods (label, isbn_with_hyphens) and forwards everything else, so the view can call title directly. It is the decorator pattern with the boilerplate removed. Its drawbacks are in the listing: eql? is delegated one way only (presenter.eql?(book) is true, book.eql?(presenter) is false), and is_a?(Shelf::Book) is false because the presenter is a different object. Compare with __getobj__ when identity matters, and do not put presenters into collections that mix wrapped and unwrapped objects.

  • Closed presenter (BookCard): wraps the object and exposes only the methods the view needs. Nothing leaks, which is an advantage when the domain object has many methods or sensitive fields. It needs one method per exposed field. Choose open for convenience inside one application, closed at an API boundary.

  • Serializer (BookSerializer): converts a domain object to plain Hash, Array, String and number data. It does not know JSON; the caller generates the text with JSON.generate. Splitting these two steps lets you test the shape without parsing text and swap the format (JSON, YAML, CSV; see Serialization: JSON, YAML, CSV and Marshal).

  • Boundary parser (BookParser): the inverse. JSON.parse(json, symbolize_names: true) yields symbol keys, and a case/in pattern both validates the shape and binds the parts (String => isbn). Anything that does not match raises NoMatchingPatternKeyError (a missing key; it is a subclass of NoMatchingPatternError) or plain NoMatchingPatternError (a wrong type or value, with a message such as String === 5 does not return true), so malformed input is rejected at the edge, before it reaches the domain. Convert to domain values immediately (Isbn.new) so later code never handles raw strings.

Prefer symbols as hash keys inside the program and convert at the boundary: symbol keys are the convention for data you control, and string keys are what comes off the wire.

Global state, fiber-local storage and thread-local storage

Anything reachable from a constant, a class-level instance variable, or $ global is shared by every thread and fiber. Some sharing is wanted (a frozen configuration), and the rest is a source of races and of tests that depend on order. A few rules cover most of it:

  • Prefer an argument. Pass the current member to the use case that needs it. This is the clearest option and the one that costs nothing.

  • Freeze what you share. Build a configuration object once, freeze it, and publish it. Mutable state shared between threads needs a Mutex (the shelf Catalog guards its store with one).

  • Initialize eagerly. @instance ||= new, the textbook lazy singleton, is not thread-safe: two threads can both see nil, both construct, and one result is lost. Assign the object when the file loads, or guard creation with a Mutex, or use the Singleton module from the singleton default gem, which builds the instance under a lock. Note that the Shelf.config method in the running gem uses @config ||= ..., which is acceptable for a value read at start-up but would not be for one created while serving concurrent requests.

  • Use a per-request context for what is genuinely ambient, such as the signed-in member in a handler or a request id in a log line, and reset it deterministically.

Ruby offers three storage scopes for ambient data, and the names are misleading:

Mechanism Visible from Notes

Fiber[:key] and Fiber[:key] = value

The current fiber and, as a copy, every fiber and thread created from it afterwards (since 3.2)

Keys must be symbols. Fiber.new(storage: nil) starts empty; Fiber.new(storage: hash) sets it explicitly.

Thread.current[:key]

The current fiber only (despite the name)

Fiber-local. A fiber created by Fiber.new or a scheduler sees nil.

Thread.current.thread_variable_get(:key)

Every fiber of that thread, but not other threads

Truly thread-local. Not inherited by Thread.new.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Per-request context: explicit, resettable, and isolated per fiber.
  module Current
    KEY = :shelf_member

    def self.member = Fiber[KEY]

    def self.with(member)
      previous = Fiber[KEY]
      Fiber[KEY] = member
      yield
    ensure
      Fiber[KEY] = previous
    end
  end
end

Shelf::Current.member # => nil

Shelf::Current.with("ana") do
  Shelf::Current.member                                 # => "ana"
  Fiber.new { Shelf::Current.member }.resume            # => "ana"
  Thread.new { Shelf::Current.member }.value            # => "ana"
  Fiber.new { Shelf::Current.with("ben") { Shelf::Current.member } }.resume # => "ben"
  Shelf::Current.member # => "ana"
end

Shelf::Current.member # => nil

# The older, fiber-local hash behind Thread#[]: not inherited by new fibers.
Thread.current[:request_id] = 42
Thread.current[:request_id]                             # => 42
Fiber.new { Thread.current[:request_id] }.resume        # => nil

# Thread variables are shared by every fiber of the thread.
Thread.current.thread_variable_set(:locale, :es)
Fiber.new { Thread.current.thread_variable_get(:locale) }.resume # => :es
Thread.new { Thread.current.thread_variable_get(:locale) }.value # => nil

The Current module is a pattern, not a library: a tiny object with a block form that sets a value, yields, and restores the previous value in ensure. The block form guarantees cleanup even when the body raises. In the listing, a new fiber and a new thread both inherit "ana", a nested with shadows it, and the outer value is back afterwards. The fiber-local Thread.current[:request_id] is invisible to a new fiber, which is why code written for threads can break when moved to fibers with a scheduler. Fiber storage was designed to fix this. Assigning nil to a storage key deletes it, and Fiber#storage= is documented as experimental, so assign individual keys rather than the whole hash.

Two cautions. Ambient state makes dependencies invisible, so keep it to a few well-named values and never store domain objects that are expensive to build. And in tests, reset it in teardown (or use the block form) so one test cannot leak a signed-in user into the next.

Configuration objects

Configuration is data read from the environment (variables, files, command-line options) that changes between deployments. Treat it as a value: read it once at the edge, validate it, freeze it, and pass it down. Do not call ENV from the middle of the program.

# frozen_string_literal: true

require "shelf"
require "yaml"

module Shelf
  # Configuration object: built once at the edge, validated, frozen, and passed in.
  Settings = Data.define(:store, :loan_days, :base_url, :on_overdue) do
    # Any object with #fetch works: ENV in production, a Hash in tests.
    def self.from_env(env = ENV)
      new(
        store: env.fetch("SHELF_STORE", "memory").to_sym,
        loan_days: Integer(env.fetch("SHELF_LOAN_DAYS", "14")),
        base_url: env.fetch("SHELF_BASE_URL"),
        on_overdue: ->(loan) { warn "overdue: #{loan}" }
      )
    end

    def initialize(store:, loan_days:, base_url:, on_overdue: nil)
      raise ArgumentError, "loan_days must be positive" unless loan_days.positive?

      super(store:, loan_days:, base_url: base_url.delete_suffix("/"), on_overdue: on_overdue || ->(_loan) {})
    end
  end
end

env = { "SHELF_BASE_URL" => "https://library.example/", "SHELF_LOAN_DAYS" => "21" }
settings = Shelf::Settings.from_env(env)
settings.to_h.except(:on_overdue) # => {store: :memory, loan_days: 21, base_url: "https://library.example"}
settings.frozen?                  # => true
settings.on_overdue.call("Dune")  # => nil

begin
  Shelf::Settings.from_env({})
rescue KeyError => e
  e.message # => "key not found: \"SHELF_BASE_URL\""
end

begin
  Shelf::Settings.from_env(env.merge("SHELF_LOAN_DAYS" => "two weeks"))
rescue ArgumentError => e
  e.message # => "invalid value for Integer(): \"two weeks\""
end

# Per-environment files with YAML anchors, loaded safely (aliases must be allowed explicitly).
yaml = <<~YAML
  default: &default
    loan_days: 14
    store: memory
  production:
    <<: *default
    loan_days: 28
YAML

YAML.safe_load(yaml, aliases: true, symbolize_names: true).fetch(:production) # => {loan_days: 28, store: "memory"}

begin
  YAML.safe_load(yaml)
rescue Psych::AliasesNotEnabled => e
  e.message # => "Alias parsing was not enabled. To enable it, pass `aliases: true` to `Psych::load` or `Psych::safe_load`."
end
  • ENV.fetch. ENV["X"] returns nil for a missing variable, which turns a deployment mistake into a NoMethodError far away. fetch with no default raises KeyError at start-up, naming the variable. fetch with a default documents the fallback in one place. The Hash used in the example responds to fetch the same way, so the method takes env as an argument and tests need no ENV manipulation.

  • Parse strictly. Every ENV value is a String. Integer("14") converts and Integer("two weeks") raises ArgumentError; to_i would silently return 0. Normalise at the same place (the trailing slash of the URL).

  • A Data configuration object is frozen and comparable. Its initialize validates, and the factory from_env reads. Splitting the two keeps validation available to code that builds settings by hand.

  • Lambdas as configuration. A callback such as on_overdue is configuration too: the default does nothing, production supplies a real one, and a test supplies a recorder. The example’s default writes with warn, which goes to standard error, not standard output.

  • YAML files and anchors. Per-environment files commonly define a default mapping with an anchor (&default) and merge it with <<: *default. Aliases are rejected by YAML.safe_load unless you pass aliases: true, which is the safe way to allow them; Psych::AliasesNotEnabled is the error otherwise. symbolize_names: true yields symbol keys. Never use YAML.load on untrusted input (see Security).

  • Logging. Send diagnostics to a Logger object passed in as a collaborator rather than calling puts; Logger is a bundled gem in Ruby 4.0, so a project run under Bundler lists logger in its Gemfile.

  • Secrets belong in the environment or a secret store, never in the repository; the example values are public placeholders.

See Ruby Command Line and Environment for ENV itself.

The composition root and test seams

Every dependency in the examples above is passed in. Someone has to create the real objects: the composition root, the one place that knows every concrete class. It sits at the edge (the entry point of a script or a service), builds the graph once, and hands the use cases their collaborators. The class below does that for the shelf gem.

# frozen_string_literal: true

require "shelf"
require "shelf/lend_book"
require "shelf/settings"

module Shelf
  # Composition root: the one place that knows every concrete class.
  class Application
    STORES = { memory: Store::Memory }.freeze

    attr_reader :catalog

    def initialize(settings, clock: -> { Time.now })
      @settings = settings
      @catalog = Catalog.new(store: STORES.fetch(settings.store).new)
      @loans = Loans::Memory.new
      @clock = clock
    end

    def lend(isbn)
      LendBook.call(catalog:, isbn:, loans: @loans, clock: @clock, loan_days: @settings.loan_days)
    end
  end
end

settings = Shelf::Settings.from_env({ "SHELF_BASE_URL" => "https://library.example", "SHELF_LOAN_DAYS" => "7" })
app = Shelf::Application.new(settings, clock: -> { Time.utc(2026, 10, 18) })

isbn = Shelf::Isbn.new("978-0-306-40615-7")
app.catalog.add(Shelf::Book.new(isbn, "The Dispossessed", Shelf::Author.new("Ursula K. Le Guin")))

app.lend(isbn).due_at.iso8601 # => "2026-10-25T00:00:00Z"
app.lend(isbn).reason         # => :already_lent

Source: Time#iso8601 (core since Ruby 3.4; earlier versions need require "time")

The listing loads the LendBook and Settings listings from files, as described at the top of the page.

Application is the only class that mentions both Store::Memory and LendBook; the use cases know neither each other nor an infrastructure class. A new store becomes a new entry in STORES and a new setting, and no use case changes. In a larger program, keep the root small and free of logic. If it grows branches, they are probably configuration or policy that belongs in an object of its own.

Because collaborators are arguments, the unit tests need no mocking library. A hand-written fake for the loan repository (any object with lent? and add) and a lambda for the clock are enough. The test below runs with the Minitest that ships with Ruby 4.0 (a bundled gem; see Testing with Minitest and RSpec and Test Doubles for the alternatives).

# frozen_string_literal: true

require "minitest/autorun"
require "shelf"
require "shelf/lend_book"

class LendBookTest < Minitest::Test
  # A hand-written fake: any object with #lent? and #add can stand in for the real repository.
  class FakeLoans
    attr_reader :added

    def initialize(lent: [])
      @lent = lent
      @added = []
    end

    def lent?(isbn) = @lent.include?(isbn)

    def add(isbn, due_at) = @added << [isbn, due_at]
  end

  def setup
    @isbn = Shelf::Isbn.new("978-0-306-40615-7")
    book = Shelf::Book.new(@isbn, "The Dispossessed", Shelf::Author.new("Ursula K. Le Guin"))
    @catalog = Shelf::Catalog.new(store: Shelf::Store::Memory.new).add(book)
    @clock = -> { Time.utc(2026, 10, 18) }
  end

  def test_lends_for_the_configured_number_of_days
    loans = FakeLoans.new
    result = Shelf::LendBook.call(catalog: @catalog, isbn: @isbn, loans:, clock: @clock, loan_days: 3)

    assert_equal Time.utc(2026, 10, 21), result.due_at
    assert_equal [[@isbn, Time.utc(2026, 10, 21)]], loans.added
  end

  def test_refuses_a_book_that_is_already_lent
    loans = FakeLoans.new(lent: [@isbn])
    result = Shelf::LendBook.call(catalog: @catalog, isbn: @isbn, loans:)

    assert_equal :already_lent, result.reason
    assert_empty loans.added
  end
end

Source: Minitest (a bundled gem, so it is documented in its own repository, not on docs.ruby-lang.org)

Running it prints a summary line; the seed and timing vary from run to run:

2 runs, 5 assertions, 0 failures, 0 errors, 0 skips

Source: Minitest

Notice what the test exercises: the rule "a book that is lent is refused" and the date arithmetic, with no files, no clock and no global state. Writing the test first often shows the shape of the constructor: if the test needs five collaborators, the class has too many responsibilities. A spec is a design tool as much as a safety net.

From plain Ruby to Rails

Rails is outside the scope of this section, but the ideas on this page carry over. A Rails application is the same four layers with the framework supplying much of the infrastructure and presentation: ActiveRecord models mix persistence into domain objects, controllers and views are presentation, and conventions replace the explicit wiring done by hand above. Service objects, query objects, form objects, policy objects, presenters and decorators are the same patterns used to keep logic out of controllers and models; Current-style request context, per-environment configuration files and credentials correspond to the fiber-local storage and configuration objects shown here. The plain-Ruby versions are useful in a Rails codebase for the same reason they are useful here: they are ordinary objects that need no framework to be created, called and tested.

Where the books are outdated

Two books informed this page, listed in the bibliography. Their architecture advice is sound, but several details predate Ruby 3.4 or 4.0 and are worth correcting when you meet them in older code or tutorials:

Older idiom Current approach

class Point < Struct.new(:x, :y) or class Point < Data.define(:x, :y)

Assign to a constant and pass a block: Point = Data.define(:x, :y) do ... end. keyword_init: true is unnecessary for keyword construction of a Struct since 3.2, and Data accepts both forms.

NotImplementedError for abstract methods

NoMethodError with a clear message, or a project error. NotImplementedError is a ScriptError that a bare rescue does not catch.

inherited without super

Always call super first so other hooks keep working. Guard name, which is nil for anonymous classes.

A Concern-style self.included(base) with InstanceMethods and ClassMethods

Plain include for instance methods and extend of a ClassMethods module from included for class methods; InstanceMethods is not needed in plain Ruby.

Lazily assigned singletons (@instance with the or-assign operator)

Eager assignment, a Mutex, or the Singleton module: the lazy form is not thread-safe.

Thread.current[:x] as "thread-local" storage

It is fiber-local. Use Fiber[:x] (3.2) for inherited ambient state and thread_variable_get for true thread-locals.

"Fibers yield on I/O automatically"

Only non-blocking fibers with a Fiber::Scheduler do; see Fibers and the Fiber Scheduler.

case/in with no else on open-ended input

Add else, or accept NoMatchingPatternError for a closed set of types.

rescue Net::HTTPError as the only network handler

Network failures arrive as Errno::*, Socket and Timeout errors as well; see Networking and HTTP.

DateTime and {:a=>1} output

Time, and the Ruby 3.4 inspect format {a: 1}; error messages read undefined method 'x' for nil.

"`Logger`, Benchmark and Observable are standard library"

They are bundled gems in 4.0 (observer since 3.4): list them in the Gemfile of a Bundler project.

_1 as the only implicit block parameter

it (3.4) for a single parameter; _1, _2 for several.

Common pitfalls

Mistake What happens Fix

A default keyword argument that builds shared state (loans: Loans::Memory.new)

Every call gets a private, empty repository, so state "disappears" between calls.

Pass the shared object from the composition root; keep defaults for stateless collaborators.

Domain classes requiring infrastructure (require "json" and file reads in Book)

Tests need files and the domain cannot be reused.

Keep I/O in adapters; give the domain plain data.

A service object with several public methods

It becomes a mini controller with hidden state between calls.

One public method, call; split the others into their own objects.

Rescuing exceptions for expected outcomes

Control flow hidden in rescue, slow and easy to over-catch.

Return a result object; reserve exceptions for the unexpected.

ENV["X"] scattered through the code

Missing variables surface as nil far from the cause.

Read once with ENV.fetch in a settings object.

Thread.current[:x] for request context in code that may run on fibers

The value is nil in other fibers.

Use Fiber[:x] with a block-scoped setter.

Mutating a presenter’s wrapped object

The view changes the domain state.

Present frozen values or closed presenters.

is_a?(Shelf::Book) on a delegator

False, because a SimpleDelegator is a different object.

Duck-type, or unwrap with __getobj__.

One huge Application object that does everything

The composition root becomes a god class.

Keep it to construction; put behaviour in use cases.

References