Visibility and Encapsulation

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. Encapsulation is the practice of exposing a small, deliberate interface and keeping everything else free to change. Ruby supports it with three method visibilities (public, protected and private), a separate visibility for constants (private_constant), and a handful of conventions around them. Unlike Java, C# or Swift, none of this is a security boundary: visibility is a rule about how a method is called, it is checked when the call happens, and reflection can step around it on purpose. This page covers the rules exactly, the syntax that sets them (and what each form returns), the traps in the scope of the private mode switch, class-level and constant visibility, module_function, top-level methods, the ways round the rules, the reflection queries, and how to design a narrow public surface that stays easy to refactor.

It builds on Classes and Objects (instance variables, attr_*, initialize), Methods and Parameters (what def returns) and Modules and Mixins. The lookup rules that decide which method is found are in Method Lookup and super, class-level methods in Singleton Classes and Class-Level Behavior, and the reflection that bypasses visibility in Dynamic Dispatch and Method Definition and Reflection, Introspection and Tracing. Refinements, the controlled alternative to monkey patching, are in Refinements and Monkey Patching; immutable value objects, which need far less hiding, are in Struct, Data and Value Objects. Design advice continues in Idioms, Style and Clean Code and SOLID and Design Patterns. For how other languages control access, see Java, C#, Kotlin, Swift access control, PHP visibility, Rust modules and visibility and Python (which has only naming conventions).

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 running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). Two cops are relaxed on purpose: Style/AccessModifierDeclarations is disabled because the examples show the inline private def form next to the grouped one, and a few lines carry a rubocop:disable comment where an example shows a redundant form deliberately. The output shown in # => comments, and in the output blocks, is the real output. Each file is self-contained and uses the names of the shelf library catalogue used throughout this section (Shelf, Book, Isbn, Author, Catalog), run with ruby -Ilib so that require "shelf" finds the gem (see the shelf gem source).

Encapsulation in Ruby

What is hidden, and what is not

Ruby has no package, internal, friend or final keyword and no per-file privacy. What it has is a small set of mechanisms, each with a different reach:

  • Instance variables are always private to the object. There is no syntax for reading obj.@name from outside, so the only way to expose state is a method: attr_reader, attr_writer, attr_accessor, or one you write.

  • Methods are public by default and can be made protected or private. The visibility belongs to the method entry in a class or module, not to the name.

  • Constants are public by default and can be hidden from scoped lookup (Mod::NAME) with private_constant.

  • Local variables are visible only in their lexical scope; blocks and define_method bodies capture them, which makes closures the strongest hiding mechanism Ruby has (see Real information hiding).

The consequence is that "private" in Ruby describes the interface of an object: which messages other objects are meant to send it. The guard exists to stop accidents and to document intent, and it is checked against the form of the call (the receiver), never against the identity of the caller’s class, with one exception, protected.

The three visibilities

Visibility Who may call it Typical use

public

Anyone, with any receiver. The default for methods defined with def.

The contract: the messages callers are meant to send.

protected

A method call whose receiver is explicit, when the caller’s self is an instance of the class or module that owns the method (subclass instances included).

Comparison operators and similar methods that need the other object’s internals without making them public.

private

A method call with no explicit receiver, or with the literal self as receiver (Ruby 2.7 and later). Never other.method, even for another instance of the same class.

Helpers, steps of an algorithm, template-method hooks, setters used internally.

The next figure is the complete decision for a call written as receiver.name(args). Reflective calls (send, __send__, Method#call, instance_eval and friends) skip the visibility check, which is why the rules are advisory.

flowchart TD S(["receiver.name(args)"]) --> L{"method found in the receiver's ancestors?"} L -->|no| MM["method_missing runs; by default it raises NoMethodError: undefined method"] L -->|yes| V{"visibility of the method found"} V -->|public| OK(["method runs"]) V -->|private| R{"receiver omitted, or the literal self?"} R -->|yes| OK R -->|no| MM2["method_missing runs; by default it raises NoMethodError: private method called"] V -->|protected| P{"is the caller's self a kind_of? the owner of the method?"} P -->|yes| OK P -->|no| MM3["method_missing runs; by default it raises NoMethodError: protected method called"] RF["send, __send__, Method#call, instance_eval, bind_call"] -.->|skips the check| OK

Three details in the figure matter in practice. The check is made against the method that lookup finds first, so a private method in a subclass hides a public method of the same name in the parent. A failed check goes through method_missing, which is how a catch-all method_missing can accidentally answer for a private method (shown in the NoMethodError example below). And the check happens at call time: you can define a method that calls a private method wrongly, and nothing complains until it runs.

A first complete example

A Member of the library has a small public interface (borrow, books, owes_more_than?), a protected reader that only other members may use, and a private guard method. The class diagram uses the UML markers for the three levels (+ public, # protected, - private).

classDiagram class Member { +name +borrow(book) +books() +owes_more_than(other) #fines_cents() -ensure_below_limit() }
# frozen_string_literal: true

require "shelf"

module Shelf
  # A library member: the public API is small, the details are hidden.
  class Member
    LIMIT = 2

    attr_reader :name

    def initialize(name, fines_cents: 0)
      @name = name
      @fines_cents = fines_cents
      @books = []
    end

    # Public: part of the contract with callers.
    def borrow(book)
      ensure_below_limit
      @books << book
      self
    end

    def books = @books.dup

    # Callable on another Member, because the caller is a Member too.
    def owes_more_than?(other) = fines_cents > other.fines_cents

    protected

    attr_reader :fines_cents

    private

    def ensure_below_limit
      raise Shelf::Error, "#{name} has reached the limit of #{LIMIT} books" if @books.size >= LIMIT
    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:)
ana = Shelf::Member.new("Ana", fines_cents: 250)
ben = Shelf::Member.new("Ben")

ana.borrow(book).books.size # => 1
ana.owes_more_than?(ben) # => true

begin
  ana.fines_cents
rescue NoMethodError => e
  e.message # => "protected method 'fines_cents' called for an instance of Shelf::Member"
end

begin
  ana.ensure_below_limit
rescue NoMethodError => e
  e.message # => "private method 'ensure_below_limit' called for an instance of Shelf::Member"
end

begin
  ana.borrow(book).borrow(book)
rescue Shelf::Error => e
  e.message # => "Ana has reached the limit of 2 books"
end

Notice what the example relies on. books returns a copy (dup), so callers cannot mutate the internal Array through the reader: hiding the variable is not enough if a method hands out the object. LIMIT is an ordinary public constant, and the private keyword above ensure_below_limit does not affect constants (see Constants: private_constant, public_constant and deprecate_constant).

public: the default, and the methods Ruby makes private for you

A method defined with def is public unless the surrounding class body has switched mode (see Choosing a visibility: the four syntactic forms). public with names re-publishes methods, and public with no arguments switches the mode back after a private or protected section. A public method is a promise: anything that can be called from outside will eventually be called from outside, and removing it later is a breaking change.

Five method names are an exception. initialize, initialize_copy, initialize_clone, initialize_dup and respond_to_missing? are always private, however they are written and wherever the mode switch stands. They are hooks that the runtime calls (new calls initialize, dup calls initialize_dup and then initialize_copy), and calling them directly would corrupt an object’s state or bypass the protocol. Most of the infrastructure that Ruby gives every object is private for the same reason: Kernel#puts, format, raise, require and Integer() are private instance methods of Kernel, so "text".puts is a NoMethodError, while puts "text" works from anywhere because it has no receiver.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A receipt printed when a book is returned.
  class Receipt
    # No access modifier anywhere: Ruby makes these hooks private by itself.
    def initialize(book) = @book = book

    def initialize_copy(source)
      super
      @book = source.instance_variable_get(:@book)
    end

    def respond_to_missing?(name, include_private = false) = name == :total || super

    def to_s = "Receipt for #{@book.title}"
  end
end

Shelf::Receipt.private_instance_methods(false).sort # => [:initialize, :initialize_copy, :respond_to_missing?]
Shelf::Receipt.public_instance_methods(false).sort # => [:to_s]

author = Shelf::Author.new("Octavia E. Butler")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("9780306406157"), title: "Kindred", author:)
receipt = Shelf::Receipt.new(book)

begin
  receipt.initialize(book)
rescue NoMethodError => e
  e.message # => "private method 'initialize' called for an instance of Shelf::Receipt"
end

receipt.dup.to_s # => "Receipt for Kindred"
receipt.respond_to?(:total) # => true
Object.private_method_defined?(:puts) # => true
Kernel.private_method_defined?(:format) # => true
BasicObject.private_instance_methods(false).sort.first(2) # => [:initialize, :method_missing]

The same listing shows the other side of the rule: BasicObject itself defines private hooks (initialize, method_missing, singleton_method_added and two more), which is why you override method_missing as a private method by convention even though a public one also works (a public method_missing lets callers invoke it directly).

private

The rule: no explicit receiver

A private method can be called only in the function-call form, name(args) or just name, where the receiver is implicitly self. The consequence that surprises programmers from Java and C# is that private is per call form, not per class: another instance of the same class is still "someone else", so other.balance fails even inside Account. Ruby’s protected exists for exactly that case.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Private is a rule about the call site, not about the object: a second
  # Account cannot use the first one's private methods.
  class Account
    def initialize(cents) = @cents = cents

    def same_balance_as?(other) = balance == other.balance

    private

    def balance = @cents
  end

  # A catch-all that also answers for names that exist but are private.
  class Forgiving
    def method_missing(name, *args)
      name.start_with?("fetch_") ? "ghost #{name} #{args.inspect}" : super
    end

    def respond_to_missing?(name, include_private = false) = name.start_with?("fetch_") || super

    private

    def fetch_title = "real private method"
  end
end

mine = Shelf::Account.new(500)
begin
  mine.same_balance_as?(Shelf::Account.new(500))
rescue NoMethodError => e
  [e.name, e.receiver.class, e.args, e.private_call?] # => [:balance, Shelf::Account, [], false]
  e.message # => "private method 'balance' called for an instance of Shelf::Account"
end

begin
  mine.instance_eval { missing_helper(1) }
rescue NoMethodError => e
  [e.private_call?, e.message] # => [true, "undefined method 'missing_helper' for an instance of Shelf::Account"]
end

forgiving = Shelf::Forgiving.new
forgiving.fetch_title # => "ghost fetch_title []"
forgiving.send(:fetch_title) # => "real private method"
forgiving.fetch_author # => "ghost fetch_author []"
forgiving.respond_to?(:fetch_title) # => true

begin
  forgiving.other
rescue NoMethodError => e
  e.message # => "undefined method 'other' for an instance of Shelf::Forgiving"
end

NoMethodError carries the details: name is the method, receiver the object, args the arguments given, and private_call? tells how the call was written, which is true only for a function-style call (no receiver) that failed, not for a private method called with an explicit receiver. That is why the first handler printed false. The second part of the example shows the interaction with method_missing: a failed visibility check dispatches to method_missing like any unknown name, so a catch-all that answers every name starting with fetch_ also answers for the private fetch_title. send finds the real private method because it skips the check and never reaches method_missing. Guard such handlers with a narrow pattern, call super for everything else (as the example does), and keep respond_to_missing? consistent.

The literal self exception (Ruby 2.7)

Before Ruby 2.7 the only private methods that could be called with self. in front were setters (self.position = 1), because the assignment syntax needs an explicit receiver. Since Ruby 2.7 any private method can be called with the literal self as receiver, which is why self.advance_position is no longer an error. The rule is syntactic: it applies to the keyword self written directly as the receiver. A variable that holds the same object, a parenthesised (self), and a method that returns self do not qualify. The scope operator form self::name and the safe navigation form self&.name are parsed with a literal self and are accepted.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A reservation queue position; the setter is private.
  class Hold
    attr_reader :title

    def initialize(title, position)
      @title = title
      self.position = position
    end

    def advance
      self.position -= 1
      self
    end

    def position_text = "#{title}: place #{position}"

    # Different ways of writing the receiver of a private call. RuboCop flags the
    # redundant forms, so its cops are switched off for this one method.
    # rubocop:disable Style/RedundantSelf, Style/ColonMethodCall, Lint/RedundantSafeNavigation
    # rubocop:disable Style/RedundantParentheses, Metrics/MethodLength
    def receivers
      me = self
      forms = {
        implicit: -> { position },
        literal_self: -> { self.position },
        scope_operator: -> { self::position },
        safe_navigation: -> { self&.position },
        parenthesised: -> { (self).position },
        through_variable: -> { me.position }
      }
      forms.transform_values do |form|
        form.call
      rescue NoMethodError => e
        e.message
      end
    end
    # rubocop:enable Style/RedundantSelf, Style/ColonMethodCall, Lint/RedundantSafeNavigation
    # rubocop:enable Style/RedundantParentheses, Metrics/MethodLength

    # A local variable shadows the private setter: it does NOT call position=.
    def reset_wrongly
      position = 0
      [position, self.position]
    end

    private

    attr_accessor :position
  end
end

hold = Shelf::Hold.new("Kindred", 3)
hold.advance.position_text # => "Kindred: place 2"
results = hold.receivers
results.values_at(:implicit, :literal_self, :scope_operator, :safe_navigation) # => [2, 2, 2, 2]
results[:parenthesised] # => "private method 'position' called for an instance of Shelf::Hold"
results[:through_variable] # => "private method 'position' called for an instance of Shelf::Hold"

begin
  hold.position
rescue NoMethodError => e
  e.message # => "private method 'position' called for an instance of Shelf::Hold"
end

begin
  hold.position = 1
rescue NoMethodError => e
  e.message # => "private method 'position=' called for an instance of Shelf::Hold"
end

hold.reset_wrongly # => [0, 2]
hold.position_text # => "Kindred: place 2"
hold.send(:position) # => 2

Two lessons come out of the example. First, a private writer needs the explicit self., both for self.position = x and for compound assignment, self.position -= 1, because without self the parser reads position = x as the creation of a local variable and never calls the writer; that is what reset_wrongly shows with its [0, 2] result. Second, RuboCop’s Style/RedundantSelf knows this and flags self. only where it is not required, which is why the demonstration of the redundant forms had to switch the cops off. In normal code the only self. you write is on a setter, on a call to a method that shares its name with a local variable, or on a method such as class whose name is also a keyword.

Private methods and inheritance

Since private looks only at the call form, a private method of a parent class is visible to every subclass: the subclass body calls fee_cents or helper without a receiver and it works. This is the opposite of Java and C++ (where private stops at the class boundary and protected is the inheritance level) and is why Ruby code is full of private methods that subclasses call and override. The rest of this page keeps it in mind: a private method is part of the protocol between a class and its subclasses, not a sealed detail.

protected

A protected method may be called with an explicit receiver, but only from code whose self is an instance of the class or module that defines the method. The official documentation puts the typical use plainly: protected visibility is most often applied to == and other comparison methods, where the author does not want to expose an object’s state to every caller but still needs to read it from another object of the same kind.

The check compares the caller’s self with the owner of the method, using kind_of?. That has three consequences, all shown in the next example:

  • Instances of different subclasses of the owner can call each other’s protected methods (a Student can read the rank of a Teacher when both inherit it from Patron).

  • A module’s methods run with the includer as self, so a module included into the owner can call the protected method (rank_of).

  • An unrelated class, such as Auditor, cannot, even if it holds a Patron.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Members with a protected rank: comparable, but the rank is not public API.
  class Patron
    include Comparable

    attr_reader :name

    def initialize(name, loans_count)
      @name = name
      @loans_count = loans_count
    end

    def <=>(other) = other.is_a?(Patron) ? rank <=> other.rank : nil

    protected

    def rank = @loans_count
  end

  class Student < Patron; end
  class Teacher < Patron; end

  # Not a Patron: the same call is rejected.
  class Auditor
    def peek(patron) = patron.rank
  end

  # A module included into Patron: its methods run with a Patron as self.
  module Ranking
    def rank_of(other) = other.rank
  end
  Patron.include(Ranking)
end

ana = Shelf::Student.new("Ana", 4)
ben = Shelf::Teacher.new("Ben", 9)

[ana < ben, ben < ana] # => [true, false]
[ben, ana].min.name # => "Ana"
ana.between?(ana, ben) # => true
ana.rank_of(ben) # => 9

begin
  ana.rank
rescue NoMethodError => e
  e.message # => "protected method 'rank' called for an instance of Shelf::Student"
end

begin
  Shelf::Auditor.new.peek(ana)
rescue NoMethodError => e
  e.message # => "protected method 'rank' called for an instance of Shelf::Student"
end

method_name = :rank
begin
  ana.public_send(method_name)
rescue NoMethodError => e
  e.message # => "protected method 'rank' called for an instance of Shelf::Student"
end

ana.send(method_name) # => 4
ana.respond_to?(:rank) # => false
ana.respond_to?(:rank, true) # => true
Shelf::Patron.protected_instance_methods(false) # => [:rank]
Shelf::Patron.instance_methods(false).sort # => [:<=>, :name, :rank]

public_send honours protected as well as private, send does not, and respond_to? answers false for a protected method unless the second argument asks for private and protected methods too. protected_instance_methods lists protected names; instance_methods and methods include them, public_instance_methods and public_methods do not.

When is protected the right tool? It is useful when two objects of the same type must see each other’s internals, and no more. Most designs get along without it:

  • Value objects made with Data.define or Struct expose their members through public readers and compare by value, so there is nothing to protect (see Struct, Data and Value Objects).

  • A comparison can be built on a public to_h or a public key method (sort_key), which also works for callers that are not Patrons.

  • Where protected is used, keep the protected surface to one or two readers. Every protected method is reachable from every subclass instance and from every module that is later mixed in, which is a wider door than it looks.

The Comparable mix-in fits this pattern well: it needs only the public <=>, and the protected reader is read inside <=> where the caller’s self is a Patron. The protected detail never leaks into the public interface.

Choosing a visibility: the four syntactic forms

All the visibility methods live on Module (so they are available in every class and module body) and accept method names as Symbols or Strings. public, protected, private and module_function are private methods of Module, which is why they are written without a receiver inside the body, and why calling SomeClass.private(:name) from outside fails (see Changing visibility in subclasses and after the fact). private_class_method, public_class_method, private_constant and public_constant are public.

  • The mode switch: private (or protected, public) with no arguments. It changes the default visibility of every def that follows in the same class body, up to the end of the body, or until another switch.

  • Names after the definition: private :name, private :first, :second, private [:name] (an Array is accepted since Ruby 3.0). The methods must already exist or NameError is raised.

  • Inline: private def name ... end. This works because def returns the Symbol of the method it defines (since Ruby 2.1), and private then receives that Symbol. It reads well for a single method and is the standard way to protect a single endless method (private def helper = 1).

  • With attribute macros: private attr_reader :serial. Since Ruby 3.0 attr_reader, attr_writer, attr_accessor and attr return an Array of the Symbols they define, so they compose with every modifier.

Since Ruby 3.1 the modifiers also return something useful: with one argument the argument itself, with several arguments an Array of them, and with none, nil. Before 3.1 they returned nil in every case, which is why older code and older books treat the return value as meaningless.

Call Returns (Ruby 3.1 and later) Notes

private (no arguments)

nil

Changes the mode for the following def statements.

private :name

:name

One argument is returned unchanged.

private :a, :b

[:a, :b]

Several arguments come back as an Array.

private [:a, :b]

[:a, :b]

Array argument, Ruby 3.0 and later.

private def name; end

:name

The inline form: def returns the Symbol.

private attr_reader :x, :y

[:x, :y]

The attr_* macros return Arrays since Ruby 3.0.

module_function with names

The same rules

See module_function and extend self.

The example runs every form and prints the return values.

# frozen_string_literal: true

module Shelf
  # Every way of choosing a visibility, with the value each call returns (Ruby 3.1+).
  class Stamp
    def by_name = "private by symbol"
    def by_array = "private by array (Ruby 3.0)"
    def pair_one = "private with two names"
    def pair_two = "private with two names"

    private :by_name # => :by_name
    private [:by_array] # => [:by_array]
    private :pair_one, :pair_two # => [:pair_one, :pair_two]

    # The mode switch: affects every `def` that follows, up to the end of the class body.
    private # => nil

    def mode_one = "private from the switch"
    def mode_two = "private from the switch"

    public # => nil

    def visible = "public again"

    # Inline forms take the Symbol that `def` returns.
    private def inline_private = "private, inline" # => :inline_private
    protected def inline_protected = "protected, inline" # => :inline_protected
    public def inline_public = "public, inline" # => :inline_public

    # attr_* return the defined names (Ruby 3.0), so they compose with the modifiers.
    private attr_reader :serial # => [:serial]
    public attr_accessor :label # => [:label, :label=]
  end
end

stamp = Shelf::Stamp
stamp.public_instance_methods(false).sort # => [:inline_public, :label, :label=, :visible]
stamp.protected_instance_methods(false) # => [:inline_protected]
private_names = stamp.private_instance_methods(false).sort
private_names.first(4) # => [:by_array, :by_name, :inline_private, :mode_one]
private_names.last(4) # => [:mode_two, :pair_one, :pair_two, :serial]

Which form to use

The Ruby Style Guide asks you to assign proper visibility levels instead of leaving everything public, to indent the modifier as deep as the method definitions it governs, and to put a blank line before and after it. RuboCop enforces the indentation (Layout/AccessModifierIndentation) and the blank lines (Layout/EmptyLinesAroundAccessModifier). Which form the project uses is a style decision that Style/AccessModifierDeclarations encodes: its default EnforcedStyle: group wants the mode switch (private on a line of its own), and allows the symbol, attribute and alias forms by default (AllowModifiersOnSymbols, AllowModifiersOnAttrs, AllowModifiersOnAliasMethod). Setting it to inline makes private def the norm. Both are fine; mixing them in one class is the only wrong answer. The grouped form has the advantage of showing the public interface at the top of the file and the private tail below it, in the "stepdown" order: public methods first, then protected, then private, each private helper below the method that calls it first (the example of the Ruby Style Guide groups protected and private methods near the end of the class too).

The scope of the mode switch

The mode switch is lexically scoped to the class body (more exactly, to the current frame of the class definition), and it reaches less than people expect. The next example collects the cases.

# frozen_string_literal: true

module Shelf
  # What the `private` mode switch does and does not reach.
  class Scope
    LIMIT = 6

    def self.define_later(name, &) = define_method(name, &)

    private

    def plain_def = 1
    attr_reader :reader
    alias copy_of_plain plain_def
    define_method(:direct_define) { 3 }

    # A method that calls define_method does it from another scope: the new method is public.
    define_later(:indirect_define) { 4 }

    # Singleton methods defined with class << self have their own mode.
    class << self
      def singleton_default = 7

      private

      def singleton_hidden = 8
    end

    # A block given to Data.define has its own default visibility.
    Point = Data.define(:x) do
      def public_in_block = 9

      private

      def private_in_block = 10
    end
  end

  # Reopening the class starts again in public mode.
  class Scope
    def after_reopen = 11
  end
end

scope = Shelf::Scope
scope.private_instance_methods(false).sort # => [:copy_of_plain, :direct_define, :plain_def, :reader]
scope.public_instance_methods(false).sort # => [:after_reopen, :indirect_define]
scope.singleton_class.private_instance_methods(false) # => [:singleton_hidden]
scope.singleton_default # => 7
scope::Point.private_instance_methods(false) # => [:private_in_block]
scope.constants.sort # => [:LIMIT, :Point]

What the example shows, point by point:

  • def and attr_* obey the mode, and alias copies the visibility of the original (here the alias is private because plain_def was private at the time). alias_method does the same.

  • define_method obeys the mode only when it is called directly in the class body (direct_define is private). A helper method that calls define_method (define_later) runs in a different frame, so the method it creates is public. When you generate methods in a loop or a hook, set the visibility explicitly with private name afterwards.

  • A class << self block starts its own scope in public mode, so singleton_hidden needs its own private there. A private above def self.name has no effect on the class method (see the cops below).

  • Constants are never affected by the mode switch (LIMIT was defined before it, but one defined after would be public too).

  • The block of Data.define, Struct.new and Class.new is a fresh class body with its own mode.

  • Reopening the class resets the mode to public: the private in the first class Scope does not carry over to the second.

Calling private without arguments inside a method (for example in a helper such as def self.setup; private; end) is unreliable, and Ruby warns about it: "calling private without arguments inside a method may not have the intended effect". Use the modifier directly in the class body, or pass names.

The mistakes RuboCop finds

Four of the commonest mistakes about visibility are caught by RuboCop’s Lint cops, which is the strongest reason to run it. The file below contains all of them on purpose.

# frozen_string_literal: true

module Shelf
  # Four mistakes about visibility, kept on purpose so that RuboCop can report them.
  class Mistakes
    private

    def self.helper = 1

    TOKEN_LENGTH = 16

    def hidden = helper

    public

    def visible = hidden

    private

    protected
  end
end

Running RuboCop on it prints these offences (the cop names are the part to remember):

Offenses:
e18.rb:8:5: W: Lint/IneffectiveAccessModifier: private (on line 6) does not make singleton methods private. Use private_class_method or private inside a class << self block instead.
    def self.helper = 1
    ^^^
e18.rb:10:5: W: Lint/UselessConstantScoping: Useless private access modifier for constant scope.
    TOKEN_LENGTH = 16
    ^^^^^^^^^^^^^^^^^
e18.rb:18:5: W: [Correctable] Lint/UselessAccessModifier: Useless private access modifier.
    private
    ^^^^^^^
e18.rb:20:5: W: [Correctable] Lint/UselessAccessModifier: Useless protected access modifier.
    protected
    ^^^^^^^^^
1 file inspected, 4 offenses detected, 2 offenses autocorrectable

The fixes: put private below the def self. methods and use private_class_method (or private inside class << self) for the class methods; write constants above the private line and use private_constant to hide them; delete a modifier that has nothing after it.

Changing visibility in subclasses and after the fact

Visibility is a property of the method entry, so a subclass can change what it inherits, and a private method takes part in dynamic dispatch like any other.

  • A private method is looked up and overridden like a public one. If a parent’s public run calls a private fee_cents, a subclass that defines its own fee_cents replaces it, which is the foundation of the template-method pattern (the parent defines the skeleton, the subclasses fill in private hooks). There is no final.

  • The same fact makes accidental overriding possible: a subclass that happens to define a method with the same name as one of the parent’s private helpers silently replaces it, and the parent’s code then calls the subclass method. Give helpers distinctive names, keep the inheritance hierarchy shallow, and prefer composition (see SOLID and Design Patterns).

  • An override is public unless it is marked otherwise, even if the parent’s method was private. Forgetting private in the override turns an implementation hook into public API.

  • private :name in a subclass for a method inherited from a parent creates a new entry that forwards to the parent’s implementation (super semantics) but is private. public :name does the opposite for a private method. In both cases instance_method(:name).owner is the subclass, and public_instance_methods(false) or private_instance_methods(false) lists the name.

  • Narrowing the visibility of an inherited public method breaks substitutability: code written against the parent type fails with NoMethodError on the subclass.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A template method: `fee_cents` is a private hook that subclasses override.
  class Pricing
    def quote(days) = "#{format('%<euros>.2f', euros: days * fee_cents / 100.0)} EUR"

    def code = "BASE"

    private

    def fee_cents = 10
  end

  # Overriding a private hook is normal. Without `private` the override is public.
  class WeekendPricing < Pricing
    private

    def fee_cents = 25
  end

  # Here the override is public, so the hook is now part of the API.
  class LeakyPricing < Pricing
    def fee_cents = 50
  end

  # Narrowing an inherited public method: callers of the parent type break.
  class SealedPricing < Pricing
    private :code
  end

  # Widening a private method: `public` copies the entry into the subclass.
  class OpenPricing < Pricing
    public :fee_cents
  end
end

Shelf::Pricing.new.quote(3) # => "0.30 EUR"
Shelf::WeekendPricing.new.quote(3) # => "0.75 EUR"
Shelf::LeakyPricing.new.fee_cents # => 50
Shelf::OpenPricing.new.fee_cents # => 10

begin
  Shelf::SealedPricing.new.code
rescue NoMethodError => e
  e.message # => "private method 'code' called for an instance of Shelf::SealedPricing"
end

Shelf::Pricing.new.code # => "BASE"
Shelf::SealedPricing.instance_method(:code).owner # => Shelf::SealedPricing
Shelf::SealedPricing.private_instance_methods(false) # => [:code]
Shelf::OpenPricing.public_instance_methods(false) # => [:fee_cents]
Shelf::OpenPricing.instance_method(:fee_cents).owner # => Shelf::OpenPricing

# Module#private is itself a private method, so it needs send or class_eval from outside.
begin
  Shelf::Pricing.private(:code)
rescue NoMethodError => e
  e.message # => "private method 'private' called for class Shelf::Pricing"
end

Shelf::Pricing.class_eval { private :code } # => :code
Shelf::Pricing.private_method_defined?(:code) # => true
begin
  Shelf::Pricing.send(:private, :nope)
rescue NameError => e
  e.message # => "undefined method 'nope' for class 'Shelf::Pricing'"
end

Two methods remove a method instead of hiding it. undef_method :name blocks the name in this class, so a call raises NoMethodError even if an ancestor defines it, while remove_method :name deletes only this class’s entry, so lookup falls through to the ancestor’s definition. Use private to hide an inherited method from callers while keeping it for super, undef_method to forbid it completely (for example in an adapter that must not expose the full API of its parent), and remove_method to undo an override. All of them are covered together with alias_method and define_method in Dynamic Dispatch and Method Definition.

Class-level visibility

The mode switch never affects def self.name, so class methods have their own tools:

  • private_class_method :name makes class methods private, and accepts several names or an Array. It returns the module it was called on.

  • public_class_method :name reverses it, most commonly for new.

  • A class << self block with private inside it (or private def inside it) hides several singleton methods at once.

  • private_class_method def self.helper ... end works, because def returns a Symbol.

The classic use is the factory with a private constructor: private_class_method :new and a public factory method (from_isbn, for_book) that calls new from inside the class, where the receiver is implicit. The standard library’s Singleton module uses exactly this and adds instance.

# frozen_string_literal: true

require "shelf"
require "singleton"

module Shelf
  # A factory with a private constructor.
  class Barcode
    PREFIX = "SH"

    def self.for_book(book) = new("#{PREFIX}-#{book.isbn}")

    private_class_method :new

    attr_reader :text

    def initialize(text) = @text = text
  end

  # Private class methods are called without a receiver from other class methods.
  class Registry
    def self.reset = clear_all
    def self.snapshot = items.dup

    class << self
      private

      def items = (@items ||= [])
      def clear_all = items.clear
    end

    private_class_method def self.audit = :audited

    def self.run_audit = audit
  end

  # The standard library's Singleton makes `new` private and adds `instance`.
  class Clock
    include Singleton

    def now = Time.at(0).utc.year
  end

  class ManualBarcode < Barcode
    public_class_method :new
  end
end

author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("9780306406157"), title: "The Dispossessed", author:)
Shelf::Barcode.for_book(book).text # => "SH-9780306406157"

begin
  Shelf::Barcode.new("x")
rescue NoMethodError => e
  e.message # => "private method 'new' called for class Shelf::Barcode"
end

Shelf::Registry.snapshot # => []
Shelf::Registry.run_audit # => :audited
Shelf::Registry.reset # => []

begin
  Shelf::Registry.items
rescue NoMethodError => e
  e.message # => "private method 'items' called for class Shelf::Registry"
end

Shelf::Clock.instance.equal?(Shelf::Clock.instance) # => true

begin
  Shelf::Clock.new
rescue NoMethodError => e
  e.message # => "private method 'new' called for class Shelf::Clock"
end

# The private `new` is inherited by subclasses until they reopen it.
Shelf::ManualBarcode.new("custom").text # => "custom"
Shelf::Barcode.singleton_class.private_instance_methods(false) # => [:new]
Shelf::Barcode.private_methods(false) # => [:new, :initialize, :inherited]

# A private `new` is a convention, not a lock: allocate, dup, clone and Marshal still make instances.
barcode = Shelf::Barcode.for_book(book)
Shelf::Barcode.allocate.text # => nil
barcode.dup.text # => "SH-9780306406157"
barcode.clone.equal?(barcode) # => false
Marshal.load(Marshal.dump(barcode)).text # => "SH-9780306406157"

Three limits apply to a private new. Subclasses inherit the privacy (the singleton classes inherit too), so each subclass that should be constructible has to public_class_method :new itself. The privacy is a convention that stops callers from writing Barcode.new, not a lock: allocate, dup, clone and Marshal.load still create instances without going through the factory, and allocate skips initialize entirely. The standard Singleton module closes those holes by also making dup and clone raise and by customising marshalling; if uniqueness really matters, use it rather than re-implementing it. Finally, a private class method is still a regular method, so send (or a reopened singleton class) reaches it.

Constants: private_constant, public_constant and deprecate_constant

Constants have their own visibility, set per name with private_constant, public_constant and deprecate_constant. The private keyword, the mode switch and private_class_method never apply to constants, which is why RuboCop’s Lint/UselessConstantScoping flags a constant written below a private line.

  • A private constant can be referenced by its bare name from inside the class, its subclasses and nested classes (the lexical and inheritance lookup works), but not with a scope: Limits::SECRET_SALT raises NameError ("private constant Shelf::Limits::SECRET_SALT referenced"). That is the mechanism for hiding helper classes and internal tables inside a namespace.

  • constants and const_defined?-style listings behave accordingly: constants omits private constants, and const_get ignores the restriction, which is the reflective back door.

  • public_constant reverses private_constant. Both return the module in Ruby 4.0, so they can be chained, but their return value is rarely used.

  • deprecate_constant keeps a constant working and prints a warning when it is referenced. The warning belongs to the :deprecated category, which has been off by default since Ruby 2.7.2, so it appears only with -w, -W:deprecated or Warning[:deprecated] = true. It is the supported way to retire a public constant over several releases.

# frozen_string_literal: true

module Shelf
  # Constants have their own visibility: private_constant, public_constant, deprecate_constant.
  class Limits
    DEFAULT_DAYS = 14
    MAX_RENEWALS = 3
    SECRET_SALT = "not-a-real-salt"
    OLD_DAYS = 21

    class Helper
      def salt = SECRET_SALT
    end

    private_constant :SECRET_SALT, :Helper
    deprecate_constant :OLD_DAYS

    def self.salt_length = SECRET_SALT.length
    def self.helper = Helper.new
    def describe = "#{DEFAULT_DAYS} days, #{MAX_RENEWALS} renewals, salt #{SECRET_SALT.length}"
  end

  class StrictLimits < Limits
    def bare_reference = SECRET_SALT.length
  end
end

Shelf::Limits.new.describe # => "14 days, 3 renewals, salt 15"
Shelf::Limits.salt_length # => 15
Shelf::StrictLimits.new.bare_reference # => 15
Shelf::Limits.helper.salt # => "not-a-real-salt"
Shelf::Limits.constants.sort # => [:DEFAULT_DAYS, :MAX_RENEWALS, :OLD_DAYS]

begin
  Shelf::Limits::SECRET_SALT
rescue NameError => e
  [e.class, e.name, e.message] # => [NameError, :SECRET_SALT, "private constant Shelf::Limits::SECRET_SALT referenced"]
end

begin
  Shelf::StrictLimits::SECRET_SALT
rescue NameError => e
  e.message # => "private constant Shelf::Limits::SECRET_SALT referenced"
end

Shelf::Limits.const_get(:SECRET_SALT) # => "not-a-real-salt"
Shelf::Limits.const_defined?(:SECRET_SALT) # => true
Shelf::Limits.private_constant(:DEFAULT_DAYS) # => Shelf::Limits
Shelf::Limits.public_constant(:SECRET_SALT) # => Shelf::Limits
Shelf::Limits::SECRET_SALT.length # => 15

# Deprecated constants warn only when the :deprecated category is on (it is off by default).
Warning[:deprecated] = true
Shelf::Limits::OLD_DAYS.succ # => 22
# stderr: e10.rb:54: warning: constant Shelf::Limits::OLD_DAYS is deprecated

The first handler’s output shows the three fields of the NameError: the class, the name of the constant and the message. Notice StrictLimits::SECRET_SALT fails too: a subclass reaches the constant only through its bare name inside its own body. When a constant is both private and referenced from outside the class by design, the real problem is that the constant belongs elsewhere.

module_function and extend self

A module is a bag of methods, and a bag of utility functions (no state, no instances) is the case where visibility needs special handling. module_function turns the methods it is applied to into module functions: a public singleton method on the module (Slug.for_title) and, at the same time, a private instance method (so a class that includes the module can call for_title without a receiver but does not expose it to its own callers). Like the other modifiers it has the mode form (everything below it), and the form with names (module_function :dashes), and it returns its arguments by the rules of Ruby 3.1. It exists only on modules, not on classes (Class undefines it).

# frozen_string_literal: true

require "shelf"

module Shelf
  # module_function: every function is a public singleton method and a private instance method.
  module Slug
    module_function

    def for_title(title) = squeeze(title.downcase.gsub(/[^a-z0-9]+/, "-"))

    def squeeze(text) = text.gsub(/\A-+|-+\z/, "")
  end

  # The same module with explicit names, chosen after the definitions.
  module Dashes
    def dashes(text) = text.tr(" ", "-")
    def internal = :not_a_function

    module_function :dashes # => :dashes
  end

  # extend self: the methods stay public instance methods, and are also singleton methods.
  module Plain
    extend self # rubocop:disable Style/ModuleFunction

    def shout(text) = "#{text.upcase}!"
  end

  class Label
    include Slug

    def initialize(book) = @book = book

    def to_s = for_title(@book.title)
  end
end

author = Shelf::Author.new("Italo Calvino")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("9780306406157"), title: "If on a Winter's Night", author:)

Shelf::Slug.for_title("The Left Hand of Darkness") # => "the-left-hand-of-darkness"
Shelf::Label.new(book).to_s # => "if-on-a-winter-s-night"
Shelf::Slug.singleton_methods.sort # => [:for_title, :squeeze]
Shelf::Slug.private_instance_methods(false).sort # => [:for_title, :squeeze]
Shelf::Dashes.singleton_methods # => [:dashes]
Shelf::Dashes.public_instance_methods(false) # => [:internal]
Shelf::Plain.shout("hey") # => "HEY!"
Shelf::Plain.instance_method(:shout).owner # => Shelf::Plain

begin
  Shelf::Label.new(book).for_title("x")
rescue NoMethodError => e
  e.message # => "private method 'for_title' called for an instance of Shelf::Label"
end

# The built-in modules use the same pattern: Math.sqrt is a singleton method, sqrt a private instance method.
Math.sqrt(16) # => 4.0
Math.private_method_defined?(:sqrt) # => true
ratio = 3.14159
Kernel.format("%05.1f", ratio) # => "003.1"
Kernel.respond_to?(:format) # => true

# module_function copies the method: the two sides are different method objects.
Shelf::Slug.method(:squeeze).owner # => #<Class:Shelf::Slug>
Shelf::Slug.instance_method(:squeeze).owner # => Shelf::Slug

The two sides are copies: module_function defines the singleton method as a copy of the instance method at that moment, so the instance method and the singleton method are different method objects with different owners. Redefine one and the other does not change. Math and Kernel are built this way, which is why you can write both Math.sqrt(16) and, after include Math, a bare sqrt(16), and why Kernel.format works next to the private format that every object has.

Technique Singleton (module-level) call Instance method visibility Effect of include Notes

module_function

Public copy

Private

Functions callable without receiver, hidden from the includer’s callers

The default recommendation of RuboCop (Style/ModuleFunction) and of the Ruby Style Guide.

extend self

The same method object

Public

Methods become public API of the includer

One definition, so changes apply to both sides. RuboCop flags it by default.

def self.name

Public

None

Nothing: not part of an includer

Plain namespacing; not reusable by mixing in.

class << self with def

Public

None

Nothing

The place for private helpers of module-level code and for attr_accessor at module level.

The mixed-in private methods of a module follow the same private rule as everything else. A module included into a class can define private helper methods, the class calls them without a receiver, and nobody else can. This is the cleanest way to share an implementation detail between classes without enlarging the public interface of either. The cost is the usual mixin one: two modules that define the same private helper name collide silently, and the one included later wins (see Method Lookup and super).

Top-level methods and Kernel

A method defined with def at the top level of a script or in the irb console is not a function in the sense of other languages. It becomes a private instance method of Object, and since every class inherits from Object, every object can call it without a receiver. That is why banner("x") works from inside a method of a class that knows nothing about the script, and why "text".banner("x") is a NoMethodError: the method is private.

# frozen_string_literal: true

# A method defined at the top level becomes a private instance method of Object.
def banner(text) = "== #{text} =="

banner("Shelf") # => "== Shelf =="
Object.private_method_defined?(:banner) # => true

begin
  "text".banner("x")
rescue NoMethodError => e
  e.message # => "private method 'banner' called for an instance of String"
end

# Every object can call it implicitly, because Object is an ancestor of every class.
class Report
  def title = banner("Report")
end
Report.new.title # => "== Report =="

# `public` and `private` also exist at the top level (as singleton methods of main) and make the next def public.
public def loud(text) = text.upcase
"quiet".loud("hey") # => "HEY"

to_s # => "main"
Object.public_method_defined?(:loud) # => true

At the top level, self is a special object called main, which has its own public and private singleton methods (since Ruby 3.0 they also accept an Array of names). public def loud made the method callable on any receiver, so "quiet".loud("hey") worked: you have added a method to every object in the process, the most extreme form of monkey patching. The Ruby Style Guide’s advice for "global" helper methods is the opposite: if you really need them, add them to Kernel and keep them private, or better, put them in a module as module functions and include that module only where needed. A script with a handful of top-level helpers is fine; a library must never define any, because they leak into every object of the application that loads it.

Visibility is advisory: the ways round the rules

Ruby trusts the programmer: visibility is a communication tool rather than a lock, and send exists to call a method whatever its visibility. Every one of the following reaches a private method, a protected method, a private constant or an instance variable.

Mechanism What it reaches Honours visibility?

obj.send(:name, ...), obj.__send__(:name, ...)

Private and protected methods

No

obj.public_send(:name, ...)

Public methods only (raises for private and protected)

Yes

obj.method(:name).call, Klass.instance_method(:name).bind_call(obj)

Any method found in the ancestors

No (public_method and public_instance_method do check)

obj.instance_variable_get(:@x), instance_variable_set

Instance variables

Not applicable: variables have no visibility

obj.instance_eval { ... }, instance_exec

Runs the block with the object as self: implicit-receiver calls and @x work

No

Klass.class_eval { ... } and module_eval

Runs the block in the class body: change visibility, define methods

No

Klass.const_get(:NAME)

Private constants

No (the scoped Klass::NAME does check)

Klass.allocate, dup, clone, Marshal.load

Instances built without calling a private new

No

# frozen_string_literal: true

module Shelf
  # Something with a private method, a private constant and a hidden instance variable.
  class Vault
    PIN = "0000-not-a-real-pin"
    private_constant :PIN

    def initialize = @opened = 0

    attr_reader :opened

    private

    def open_door(who = "guest")
      @opened += 1
      "opened by #{who}"
    end
  end
end

vault = Shelf::Vault.new
name = :open_door

# Reflection is the first way round the rule: send ignores visibility, public_send honours it.
vault.send(name, "ana") # => "opened by ana"
vault.__send__(name) # => "opened by guest"

begin
  vault.public_send(name)
rescue NoMethodError => e
  e.message # => "private method 'open_door' called for an instance of Shelf::Vault"
end

# A Method object remembers its receiver and calls without any visibility check.
vault.method(name).call("ben") # => "opened by ben"
Shelf::Vault.instance_method(name).bind_call(vault) # => "opened by guest"

begin
  vault.public_method(name)
rescue NameError => e
  e.message # => "method 'open_door' for class 'Shelf::Vault' is private"
end

# Instance variables are reachable by reflection, or by running code with the object as self.
vault.instance_variable_get(:@opened) # => 4
vault.instance_variable_set(:@opened, 100) # => 100
vault.instance_eval { @opened } # => 100
vault.instance_eval { open_door("cara") } # => "opened by cara"
vault.opened # => 101
vault.instance_variables # => [:@opened]

# Reopening the class and class_eval go through the same door.
Shelf::Vault.class_eval { public :open_door }
vault.open_door("dan") # => "opened by dan"

# Constants: const_get ignores private_constant.
Shelf::Vault.const_get(:PIN).length # => 19

When going round the rule is legitimate

  • Tests and debugging. A debugger or an IRB session needs to look inside. For tests, prefer to exercise the public interface (see the design section below); reach a private method with send only as a last resort and never for protected state that the public interface already exposes.

  • Frameworks and metaprogramming. Serialization, dependency injection and DSL libraries use instance_variable_get, instance_eval and send as their building blocks (see Hooks, eval and DSLs).

  • Dynamic dispatch on a method name you control: send over a fixed table of known names is fine.

When it is dangerous

send and public_send on a user-supplied name are an injection point. With send, a request parameter such as "exit", "system" or "instance_eval" reaches private Kernel methods; even public_send exposes every public method of the receiver (delete, destroy, freeze). Always check the name against an allow-list first (see Security). The Ruby Style Guide and RuboCop’s Style/Send cop (disabled by default) prefer public_send precisely because it does not circumvent private and protected, and because a class that defines its own method named send (a mail-sending object, say) breaks plain send; use __send__ in that case.

In short: treat the rule as a contract with the reader of the code. A caller who uses send(:internal) takes responsibility for breakage in the next release.

Reflection: asking about visibility

Visibility can be queried per object and per class. The reflection methods are the tools that serialisers, test doubles and documentation generators use, and the table below lists what each includes.

# frozen_string_literal: true

require "shelf"

module Shelf
  # One method at each visibility, to compare the reflection API.
  class Probe
    def pub = :pub

    protected def prot = :prot

    private def priv = :priv
  end
end

probe = Shelf::Probe.new
own = ->(list) { list & %i[pub prot priv] }

own.call(probe.methods) # => [:pub, :prot]
own.call(probe.public_methods) # => [:pub]
own.call(probe.protected_methods) # => [:prot]
own.call(probe.private_methods) # => [:priv]
own.call(probe.singleton_methods) # => []

Shelf::Probe.instance_methods(false) # => [:pub, :prot]
Shelf::Probe.public_instance_methods(false) # => [:pub]
Shelf::Probe.protected_instance_methods(false) # => [:prot]
Shelf::Probe.private_instance_methods(false) # => [:priv]

%i[pub prot priv].map { |name| probe.respond_to?(name) } # => [true, false, false]
%i[pub prot priv].map { |name| probe.respond_to?(name, true) } # => [true, true, true]

Shelf::Probe.method_defined?(:prot) # => true
Shelf::Probe.method_defined?(:priv) # => false
Shelf::Probe.public_method_defined?(:pub) # => true
Shelf::Probe.protected_method_defined?(:prot) # => true
Shelf::Probe.private_method_defined?(:priv) # => true
Shelf::Probe.private_method_defined?(:puts) # => true
Shelf::Probe.private_method_defined?(:puts, false) # => false

Shelf::Probe.instance_method(:priv).name # => :priv
probe.public_method(:pub).call # => :pub
Shelf::Probe.public_instance_method(:pub).owner # => Shelf::Probe
Query Answers

obj.methods, Klass.instance_methods

Public and protected methods (Klass.instance_methods(false) limits the list to the class itself)

obj.public_methods, Klass.public_instance_methods

Public methods only

obj.protected_methods, Klass.protected_instance_methods

Protected methods only

obj.private_methods, Klass.private_instance_methods

Private methods only (without false the list includes every private Kernel method, with it initialize and the class’s own private methods)

obj.singleton_methods

Public and protected singleton methods

obj.respond_to?(:name)

true only for public methods (or a positive respond_to_missing?). Pass true as the second argument to include private and protected ones.

Klass.method_defined?(:name)

Public or protected. A second argument false skips inherited methods.

Klass.public_method_defined?, protected_method_defined?, private_method_defined?

One visibility each; same optional inherit argument

Klass.instance_method(:name), obj.method(:name)

Any visibility, returning an UnboundMethod or Method

Klass.public_instance_method(:name), obj.public_method(:name)

Public only; NameError ("method 'x' for class 'Klass' is private") otherwise

Method#public?, private? and protected? were added in Ruby 3.1 and removed again in Ruby 3.2, so do not use them; ask the owner (Klass.private_method_defined?(name)) instead.

Real information hiding

If the goal is to prevent access rather than to signal intent, private is the wrong tool. Four techniques give stronger guarantees:

  • Closures. A variable captured by a lambda is not an instance variable and cannot be listed by instance_variables. The Counter below keeps its count in a local variable of the factory; instance_variables lists only the @step that holds the lambda, and the only way to change the count is to call it.

  • Immutability. Freezing (freeze) stops mutation but not reading. Data.define objects are frozen and expose only their readers (see Object Lifecycle, Freezing and Copying).

  • Isolation. Ractors isolate objects between execution contexts, so a non-shareable object cannot be referenced directly from another Ractor (see Ractors). Process boundaries are stronger still.

  • Controlling what is printed. inspect, pp, error messages and logs print every instance variable, which is the usual way a token ends up in a log file. Ruby 4.0 lets Object#inspect ask a method named instance_variables_to_inspect which instance variables to show; define it private and return an Array of names (or nil for the default).

# frozen_string_literal: true

require "shelf"

module Shelf
  # Plain object: inspect, pp and error messages print every instance variable.
  class Session
    def initialize(user, token)
      @user = user
      @token = token
    end
  end

  # Ruby 4.0: a private instance_variables_to_inspect hook chooses what inspect shows.
  class QuietSession
    def initialize(user, token)
      @user = user
      @token = token
    end

    private

    def instance_variables_to_inspect = [:@user]
  end

  # State kept only in a closure: there is no instance variable to reach.
  class Counter
    def self.build
      count = 0
      new(-> { count += 1 })
    end

    def initialize(step) = @step = step

    def next = @step.call
  end
end

token = "fake-token"
open = Shelf::Session.new("ana", token)
open.inspect.sub(/0x\h+/, "0x0") # => "#<Shelf::Session:0x0 @user=\"ana\", @token=\"fake-token\">"
quiet = Shelf::QuietSession.new("ana", token)
quiet.inspect.sub(/0x\h+/, "0x0") # => "#<Shelf::QuietSession:0x0 @user=\"ana\">"

counter = Shelf::Counter.build
[counter.next, counter.next] # => [1, 2]
counter.instance_variables # => [:@step]

# Freezing protects against mutation, not against reading.
session = Shelf::Session.new("ben", token).freeze
session.frozen? # => true
session.instance_variable_get(:@user) # => "ben"

begin
  session.instance_variable_set(:@user, "eve")
rescue FrozenError => e
  e.message.include?("fake-token") # => true
end

begin
  quiet.freeze.instance_variable_set(:@user, "eve")
rescue FrozenError => e
  e.message.include?("fake-token") # => false
end

The last two handlers show the other lesson: a FrozenError message is built with inspect, so a frozen object with a secret prints it in the exception message (and from there into logs and error trackers). Defining instance_variables_to_inspect fixes both the direct inspect and the message. The @token values in the example are made-up placeholders; real credentials belong in the environment or a secrets store, never in an object that gets inspected or logged (see Security).

Designing the public surface

The mechanics above support a few design habits. They follow the guidance of the object-oriented chapters of the books listed on the section landing page, restated for current Ruby.

  • Start narrow. Make methods private by default and widen them when a caller needs them. The direction matters: turning a private method public never breaks anyone, while turning a public method private breaks every caller. A useful trick when refactoring: make a suspicious public method private and run the tests. The NoMethodError list is the exact set of external callers.

  • Hide the variables, then hide the objects. Expose state with attr_reader, not attr_accessor, unless callers really set it, and make setters private when only the class mutates the field. Return copies or frozen values for mutable collections (books returned @books.dup).

  • Tell, do not ask. An object that exposes many readers invites callers to make decisions with its data. Put the behaviour that needs the data inside the object, and the callers need fewer public methods.

  • Order the file by call depth ("stepdown"): the public interface first, each private method below the first public method that uses it, so a reader goes from the contract down to the details.

  • Keep constructors passive. initialize should assign state and validate it, not read files or call services. Factory class methods (Isbn.parse, Barcode.for_book) with a private new make the construction path explicit.

  • Test through the public interface. If a private method seems to need its own tests, it is usually a class waiting to be extracted, with its own public interface (the refactoring approach of breaking up a large class in small, tested steps).

  • Do not use protected as a default for "private but visible to subclasses". In Ruby a private method is already visible to subclasses. protected is for other instances.

  • Prefer composition over deep inheritance when the private protocol between parent and child grows: every private method a subclass calls or overrides is coupling.

  • Document the boundary. YARD’s @api private tag and the # @!visibility private directive, and RDoc’s :nodoc:, keep internals out of the generated documentation (see Documentation with RDoc and YARD).

A narrow public interface in the shelf scenario looks like this. The LoanDesk has three public methods; every step of the checkout is a private method defined below the public one that calls it.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A narrow public interface (checkout, receipt) over private steps, in stepdown order:
  # each private method sits below the first method that calls it.
  class LoanDesk
    Receipt = Data.define(:title, :due_day, :fee_cents)

    attr_reader :catalog

    def initialize(catalog: Catalog.new, today: 1)
      @catalog = catalog
      @today = today
      @lent = {}
    end

    def checkout(book, member: "guest")
      ensure_available(book)
      record_loan(book, member)
      receipt_for(book)
    end

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

    private

    def ensure_available(book)
      raise Shelf::Error, "#{book.title} is already lent" if lent?(book)
    end

    def record_loan(book, member)
      @catalog.add(book)
      @lent[book.isbn] = member
    end

    def receipt_for(book) = Receipt.new(title: book.title, due_day: due_date, fee_cents: daily_fee_cents)

    def due_date = @today + Shelf.config.loan_days

    def daily_fee_cents = 10
  end
end

author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("9780306406157"), title: "The Dispossessed", author:)
desk = Shelf::LoanDesk.new

receipt = desk.checkout(book, member: "ana")
receipt.to_h # => {title: "The Dispossessed", due_day: 15, fee_cents: 10}
desk.lent?(book) # => true
desk.public_methods(false).sort # => [:catalog, :checkout, :lent?]
private_names = desk.private_methods(false).sort
private_names.first(3) # => [:daily_fee_cents, :due_date, :ensure_available]
private_names.last(3) # => [:initialize, :receipt_for, :record_loan]

begin
  desk.checkout(book)
rescue Shelf::Error => e
  e.message # => "The Dispossessed is already lent"
end

initialize shows up in private_methods(false), as the section on the automatically private hooks explained. The Receipt data class is a public nested constant: it is part of the interface, because callers receive and read it.

Retiring a public method

Making a published method private is a breaking change. Libraries that follow semantic versioning do it in two releases: first keep the method public and make it warn, then make it private in the next major release. Kernel#warn takes uplevel: (the caller’s file and line) and category: :deprecated, so the message points at the code that must change and is silent unless deprecation warnings are on.

# frozen_string_literal: true

require "shelf"
require "stringio"

module Shelf
  # Release 1.1: the old public method still works but warns. Release 2.0: it becomes private.
  class Inventory
    def initialize(books) = @books = books

    def total = @books.size

    def book_count
      warn "Inventory#book_count is deprecated, use #total", uplevel: 1, category: :deprecated
      total
    end
  end

  class NextInventory < Inventory
    private :book_count
  end
end

def capture_stderr
  original = $stderr
  $stderr = StringIO.new
  yield
  $stderr.string
ensure
  $stderr = original
end

Warning[:deprecated] = true
inventory = Shelf::Inventory.new(%w[a b c])

capture_stderr { inventory.book_count } # => "e17.rb:36: warning: Inventory#book_count is deprecated, use #total\n"
Warning[:deprecated] = false
capture_stderr { inventory.book_count } # => ""

begin
  Shelf::NextInventory.new(%w[a]).book_count
rescue NoMethodError => e
  e.message # => "private method 'book_count' called for an instance of Shelf::NextInventory"
end

The second version of the class is the 2.0 release: private :book_count hides it, and the former callers now get NoMethodError. For constants the same job is done by deprecate_constant (see Constants: private_constant, public_constant and deprecate_constant); see Creating and Publishing Gems, and Rake for version numbering.

RuboCop cops for visibility

Cop What it checks Default

Layout/AccessModifierIndentation

private, protected and public indented like the methods they govern (EnforcedStyle: indent)

Enabled

Layout/EmptyLinesAroundAccessModifier

A blank line before and after the modifier

Enabled

Style/AccessModifierDeclarations

Mode switch (group) or private def (inline)

Enabled, group

Lint/UselessAccessModifier

A modifier that governs no method

Enabled

Lint/IneffectiveAccessModifier

private above def self.name, which does nothing

Enabled

Lint/UselessConstantScoping

A constant written under private

Pending (enabled with NewCops: enable)

Style/RedundantSelf

self. where it is not required (keeps it on setters and shadowed names)

Enabled

Style/ModuleFunction

extend self in a module

Enabled, module_function

Style/Send

send instead of public_send or __send__

Disabled

Where the books are outdated

Much of the printed advice on visibility was written for Ruby 1.9 to 2.x and is still repeated. The current rules:

  • "A private method can never be called with a receiver." The rule changed in Ruby 2.7: the literal self is allowed as the receiver for any private method. Before that only setters could use it. Books that warn against self.method for private methods are describing Ruby 2.6 and older.

  • Return values of the modifiers. Before Ruby 3.0 attr_reader returned nil, so the inline form private attr_reader :name could not work; before 3.1 private, public, protected and module_function returned nil. Today they return their arguments, so the inline forms are idiomatic.

  • Array arguments (private [:a, :b]) arrived in Ruby 3.0, and the private def idiom needs Ruby 2.1 or later.

  • Error messages. The wording of NoMethodError changed twice, and tests that match the text must follow:

    Ruby 3.2 and older:  private method `x' called for #<Foo:0x000071c4>
    Ruby 3.3:            private method `x' called for an instance of Foo
    Ruby 3.4 and later:  private method 'x' called for an instance of Foo

    Until 3.2 the message printed the receiver’s inspect, which was slow and could leak data. Prefer assert_raises(NoMethodError) and check error.name instead of matching the message.

  • send as the universal tool. Older tutorials call send for everything. Today public_send is the default for dynamic dispatch on public methods, and send or __send__ is reserved for the cases that intend to bypass visibility.

  • protected everywhere. Some books recommend protected readers for all "internal but shared" state. Value objects built with Data or Struct make that unnecessary.

  • Hiding constants by convention. private_constant has existed since Ruby 1.9.3; leading underscores or comments are no longer needed.

  • Method#public? was added in 3.1 and removed in 3.2, so articles that use it are wrong for current Ruby.

  • inspect always shows everything. True before Ruby 4.0. Now instance_variables_to_inspect controls it.

Version notes

Ruby Change

2.1

def returns the method name as a Symbol, enabling private def name.

2.5

attr_reader, define_method, alias_method, undef_method and remove_method became public methods of Module; private, protected, public and module_function stayed private.

2.7

Calling a private method with a literal self receiver is allowed. 2.7.2 made :deprecated warnings off by default.

3.0

private, public, protected, public_class_method and private_class_method accept an Array of names; attr_* return Arrays of Symbols.

3.1

private, public, protected and module_function return their arguments. Method#private? and friends added.

3.2

Method#public?, private?, protected? and the UnboundMethod equivalents removed.

3.3

NoMethodError messages name the receiver’s class ("an instance of Foo") instead of its inspect.

3.4

Error messages quote method names with an apostrophe instead of a backtick.

4.0

Object#inspect honours a private instance_variables_to_inspect method.

Common errors

Message Cause Fix

private method 'x' called for an instance of Foo

Explicit receiver on a private method

Call it without receiver or with literal self, or make it public

protected method 'x' called for an instance of Foo

Caller is not an instance of the method’s owner

Compare through public readers, or call from a method of the owner

undefined local variable or method 'x' for an instance of Foo

Local-variable-looking call to a missing method

Check the name; a private setter needs self.x = value

private constant Foo::BAR referenced

Foo::BAR with private_constant

Add a public accessor, or use the bare name inside the class

private method 'new' called for class Foo

private_class_method :new

Call the factory method

NameError "undefined method 'x' for class 'Foo'"

private :x before def x

Define the method first

References