Encapsulation and Access Control
Encapsulation means bundling an object’s data together with the methods that operate on it, and hiding the internal details behind a clean, deliberate interface. In Ruby this isn’t just a design guideline — it’s partly built into the language, because instance variables are never accessible from outside an object unless you explicitly write a method that exposes them. Access control (public, private, and protected) is how you decide exactly which parts of that interface the outside world gets to see. Getting this right is what separates a class that just happens to work from one that protects its own invariants no matter how it’s used.
Overview: How Encapsulation Works in Ruby
Every instance variable in Ruby, written as @name, belongs entirely to the object it lives on. There is no syntax that lets code outside the class reach in and read @balance directly — the only way to expose it is to define a method that returns it, such as a reader. This is different from languages where fields are accessible by default and you have to opt into hiding them; in Ruby, state is private by default, and you deliberately choose what to reveal. This is the foundation of encapsulation: the object’s internal representation can change freely as long as the public methods that other code depends on keep working the same way.
Methods, unlike instance variables, are public by default. Ruby gives you three visibility levels for methods:
- public — callable from anywhere, by anyone who has a reference to the object. This is the default for every method except
initialize, which is always private. - private — callable only without an explicit receiver, meaning only from inside the object itself (implicitly using
self). You cannot writesome_object.private_methodfrom outside the class, and generally not evenself.private_methodfrom inside it — with one important exception for setter methods, covered below. - protected — callable with an explicit receiver, but only from inside a method defined on the same class (or a subclass). This lets objects of the same type compare or operate on each other’s internal state without opening that state to the rest of the program.
The common shortcuts attr_accessor, attr_reader, and attr_writer are just method generators: they define ordinary getter and/or setter methods for you so you don’t have to write def name; @name; end by hand. attr_reader generates only a getter, attr_writer generates only a setter, and attr_accessor generates both. None of these bypass access control — the methods they generate are public by default, just like any other method, and can be placed after private to make them private too. The real design decision in encapsulation is not "how do I expose this variable" but "should this even be exposed, and if so, should reading it and writing it require different levels of trust?" A validated custom setter that rejects bad values is very different from a bare attr_accessor that lets anyone set anything.
Syntax
Ruby offers a few interchangeable ways to mark methods as private or protected:
class ClassName
def initialize
# instance variables set up here are already inaccessible from outside
end
def public_method
# part of the object's interface; callable from anywhere
end
private
def private_helper
# callable only from inside this class, with no explicit receiver
end
protected
def protected_helper
# callable from sibling instances of this class, with an explicit receiver
end
end
| Form | What it does | Example |
|---|---|---|
private (no arguments) |
Every method defined after this line becomes private, until public or protected appears |
private |
private def name; end |
Marks a single method private inline, since private returns the symbol its argument evaluated to |
private def log(msg); end |
private :name |
Makes an already-defined method private retroactively, by symbol name | private :log |
protected / public |
Same grouping behavior as private, but for the other two visibility levels |
protected |
Examples
Example 1: private helper methods and validated actions
class BankAccount
attr_reader :owner, :balance
def initialize(owner, balance = 0)
@owner = owner
@balance = balance
end
def deposit(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
@balance += amount
end
def withdraw(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
raise "insufficient funds" if amount > @balance
@balance -= amount
end
def to_s
"#{owner}'s account: $#{balance}"
end
private
def audit_log(message)
"[AUDIT] #{message}"
end
end
account = BankAccount.new("Priya", 100)
account.deposit(50)
account.withdraw(30)
puts account
puts account.balance
begin
account.audit_log("test")
rescue NoMethodError
puts "Error: cannot call a private method from outside the class"
end
Output:
Priya's account: $120
120
Error: cannot call a private method from outside the class
attr_reader exposes owner and balance for reading, but there is no writer — the only way to change @balance is through deposit and withdraw, which enforce the rule that amounts must be positive and that you can’t overdraw. audit_log is a private implementation detail; calling it with an explicit receiver (account.audit_log(...)) raises NoMethodError even though the method exists, because private methods can only be invoked implicitly, from inside the object.
Example 2: protected methods let peers compare state
class Account
def initialize(balance)
@balance = balance
end
def >(other)
balance > other.balance
end
def to_s
"Account(#{balance})"
end
protected
def balance
@balance
end
end
a = Account.new(200)
b = Account.new(150)
puts a > b
puts a.to_s
begin
a.balance
rescue NoMethodError
puts "Error: balance is protected"
end
Output:
true
Account(200)
Error: balance is protected
Here balance is protected rather than private. Inside >, the expression other.balance uses an explicit receiver (other), which would be forbidden for a private method — but it’s allowed here because the caller (a) and the receiver (b) are both instances of Account. Outside the class, a.balance still raises NoMethodError, exactly like a private method would. Protected is the right tool whenever objects of the same type need to compare or combine their internal state without opening that state to everyone else.
Example 3: a validated setter instead of a raw attr_accessor
class Temperature
def initialize(celsius:)
self.celsius = celsius
end
def celsius
@celsius
end
def celsius=(value)
raise ArgumentError, "below absolute zero" if value < -273.15
@celsius = value
end
def fahrenheit
(@celsius * 9.0 / 5) + 32
end
def to_s
"#{celsius}C (#{fahrenheit}F)"
end
end
temp = Temperature.new(celsius: 25)
puts temp
temp.celsius = 30
puts temp.fahrenheit
begin
temp.celsius = -300
rescue ArgumentError => e
puts "Rejected: #{e.message}"
end
Output:
25C (77.0F)
86.0
Rejected: below absolute zero
celsius= is a public method, but it is not the bare, unchecked writer that attr_accessor would generate. It enforces an invariant — a Temperature can never be set below absolute zero — directly at the boundary where external code tries to change the object’s state. Notice that initialize itself calls self.celsius = celsius rather than assigning @celsius directly, so the same validation applies even to the very first value the object is given.
How It Works Step by Step
When Ruby evaluates account.audit_log("test") from Example 1, it goes through roughly this sequence:
- Ruby parses the expression as a message send: receiver
account, method nameaudit_log, argument"test". - It looks up
audit_logalongaccount‘s method resolution order (its singleton class, thenBankAccount, then any included modules, then superclasses) and finds it defined onBankAccount. - Before invoking the method, Ruby checks its visibility.
audit_logwas defined after theprivatekeyword, so it is marked private. - Because the call used an explicit receiver (
account) other thanself, Ruby refuses the call and raisesNoMethodErrorinstead of running the method body.
Contrast that with a call like audit_log("test") written from inside another instance method of BankAccount — there the receiver is implicit (it’s always self), so the same visibility check passes and the method runs normally. There is one deliberate exception to the "no explicit receiver" rule for private methods: Ruby allows self.name = value even when name= is private, because writing just name = value inside the class would create a local variable instead of calling the setter — the explicit self. is required to disambiguate, so Ruby special-cases it. protected works similarly to private, except the check is relaxed to allow an explicit receiver as long as the object making the call is itself an instance of the same class or a subclass.
Common Mistakes
Mistake 1: reaching for attr_accessor when a validated setter is needed
attr_accessor is convenient, but it generates a setter with no rules attached — anything can be assigned, including values that make no sense for the object.
class BankAccount
attr_accessor :balance
def initialize(balance = 0)
@balance = balance
end
end
account = BankAccount.new(100)
account.balance = -1_000_000
puts account.balance
Output:
-1000000
Nothing stops balance from becoming wildly negative, because attr_accessor just assigns whatever it’s given. Replace the raw writer with a method that enforces the account’s rules, and use attr_reader instead so only the validated path can change the value:
class BankAccount
attr_reader :balance
def initialize(balance = 0)
@balance = balance
end
def balance=(amount)
raise ArgumentError, "balance cannot be negative" if amount.negative?
@balance = amount
end
end
account = BankAccount.new(100)
begin
account.balance = -1_000_000
rescue ArgumentError => e
puts "Rejected: #{e.message}"
end
puts account.balance
Output:
Rejected: balance cannot be negative
100
The account’s balance is now guaranteed to always satisfy the class’s own rules, no matter who is calling the setter.
Mistake 2: methods accidentally becoming private due to placement
private with no arguments changes the visibility of every method defined after it, not just the one immediately below it. It’s easy to add a new public-looking method underneath an existing private section by accident:
class Greeter
def initialize(name)
@name = name
end
private
def shout
"#{@name.upcase}!"
end
def greet
"Hello, #{@name}"
end
end
Greeter.new("Sam").greet
Output:
(raises NoMethodError: greet became private because it was defined after the private keyword)
greet was clearly meant to be the class’s public interface, but because it comes after private, it silently became private too. The fix is to define public methods before the private keyword (or use the inline form private def shout; ...; end for helpers, which doesn’t affect anything defined afterward):
class Greeter
def initialize(name)
@name = name
end
def greet
"Hello, #{@name}"
end
private
def shout
"#{@name.upcase}!"
end
end
puts Greeter.new("Sam").greet
Output:
Hello, Sam
Best Practices
- Default to
attr_readeroverattr_accessor. Only expose a writer when external code genuinely needs to change that value, and prefer a customname=method over a bareattr_writerwhenever the value has rules to enforce. - Keep the public interface small and stable; push implementation details — formatting helpers, internal calculations, logging — behind
privateso you’re free to change them later without breaking anyone who uses the class. - Reach for
protectedspecifically when instances of the same class need to inspect each other’s state, such as comparison operators (<=>,==) or methods that combine two objects of the same type. - Prefer the block form of
private/protectedfor grouping several related methods, but use the inlineprivate def name; endform when you want a single method’s visibility to be obvious right where it’s defined. - Validate in the setter, not just at the call site. If invalid state can only be prevented by remembering to check before every assignment, it will eventually slip through; validating inside
name=makes it impossible to bypass. - Don’t confuse "the instance variable is inaccessible from outside" with "the object is safe from mutation." If a reader returns a mutable object like an
ArrayorHash, callers can still mutate it in place (for exampleaccount.transactions << "oops") even without a writer — return a duplicate or a frozen copy from the reader if that matters.
Practice Exercises
- Write a
Personclass with a publicnamereader and a publicagereader, but make the age settable only through a method calledhave_birthday!that increases it by one — there should be no public way to set an arbitrary age. Verify thatperson.age = 40raisesNoMethodError. - Write a
Rectangleclass withwidthandheight, and give it aprotectedmethodareaplus a public methodbigger_than?(other)that usesareaon bothselfandotherto compare two rectangles. Confirm that callingrect.areafrom outside the class raises an error whilerect.bigger_than?(other_rect)works. - Take the corrected
BankAccountfrom Mistake 1 and add a private helper methodlog_transaction(kind, amount)thatdepositandwithdrawboth call internally to build a string like"deposit of 50", appending each result to an internal@historyarray. Add a publichistoryreader (usingattr_reader) so callers can see past transactions but never modify@historyfrom outside directly.
Summary
- Instance variables are always private to the object in Ruby; the only way anything outside can read or write one is through a method you define.
- Methods are
publicby default;privatemethods can only be called without an explicit receiver (from inside the object), andprotectedmethods can be called with an explicit receiver, but only from another instance of the same class or subclass. attr_reader,attr_writer, andattr_accessorgenerate ordinary public getter/setter methods for you — they don’t add validation, and they can be placed afterprivateto generate private accessors instead.- A custom
name=setter that raises on invalid input is often a better choice than a bareattr_accessor, because it makes it impossible for the object to end up in an invalid state. privateandprotectedwith no arguments affect every method defined afterward until another visibility keyword appears — watch your method ordering, or use the inlineprivate def name; endform to avoid surprises.self.name = valueis specifically allowed even for private setters, because Ruby needs the explicit receiver to distinguish a method call from a local variable assignment.
