Overview
Encapsulation restricts direct access to an object's internal state and requires interaction through defined interfaces. This principle binds data and methods that operate on that data into a single unit while hiding implementation details from external code. Encapsulation serves as a protective barrier that prevents external code from modifying an object's internal state in unintended ways.
The concept originated with object-oriented programming languages in the 1960s, particularly with Simula and later Smalltalk. Languages implemented encapsulation through access modifiers that control visibility of class members. Ruby implements encapsulation through method visibility controls (private, protected, public) and instance variable access restrictions.
Encapsulation addresses several software development challenges. First, it prevents tight coupling between components by limiting dependencies to public interfaces rather than internal implementations. Second, it enables developers to modify internal implementations without affecting external code that depends on the class. Third, it enforces invariants by controlling how state changes occur.
# Without encapsulation - direct state manipulation
class BankAccount
attr_accessor :balance
end
account = BankAccount.new
account.balance = 1000
account.balance -= 5000 # Negative balance allowed - invalid state
# With encapsulation - controlled state changes
class BankAccount
def initialize
@balance = 0
end
def deposit(amount)
raise ArgumentError, "Amount must be positive" if amount <= 0
@balance += amount
end
def withdraw(amount)
raise ArgumentError, "Amount must be positive" if amount <= 0
raise "Insufficient funds" if amount > @balance
@balance -= amount
end
def balance
@balance
end
end
Key Principles
Encapsulation operates on three fundamental mechanisms: information hiding, interface definition, and access control. Information hiding conceals implementation details from external code, making the internal structure opaque to clients. Interface definition establishes the public methods through which external code interacts with objects. Access control enforces visibility boundaries using language features like access modifiers.
The principle of least privilege guides encapsulation decisions. Class members default to the most restrictive access level necessary for functionality. Public interfaces expose only operations essential for client code. Internal helper methods, implementation details, and direct state access remain hidden behind access restrictions.
Encapsulation maintains a distinction between interface and implementation. The interface represents a contract specifying what operations an object provides and their expected behavior. The implementation contains the actual code that fulfills the contract. This separation allows implementation changes without breaking client code as long as the interface remains stable.
State protection forms a core aspect of encapsulation. Direct access to instance variables bypasses validation logic and allows invalid state. Controlled access through methods enables validation, logging, authorization checks, and other cross-cutting concerns at state transition points.
class Temperature
def initialize(celsius)
self.celsius = celsius
end
def celsius
@celsius
end
def celsius=(value)
raise ArgumentError, "Temperature cannot be below absolute zero" if value < -273.15
@celsius = value
end
def fahrenheit
@celsius * 9.0 / 5.0 + 32
end
def fahrenheit=(value)
self.celsius = (value - 32) * 5.0 / 9.0
end
end
Encapsulation creates abstraction layers that manage complexity. High-level code interacts with simplified interfaces while complex implementation remains hidden. This allows developers to reason about systems at appropriate abstraction levels without managing low-level details.
The Law of Demeter extends encapsulation principles by restricting the methods an object can call. An object should only interact with immediate neighbors: itself, objects passed as parameters, objects it creates, and objects in its instance variables. This prevents reaching through object chains and reduces coupling.
# Violates Law of Demeter
class Order
attr_reader :customer
end
class Customer
attr_reader :address
end
class Address
attr_reader :city
end
order = Order.new(customer: Customer.new(address: Address.new(city: "Chicago")))
city = order.customer.address.city # Chain of calls exposes internal structure
# Follows Law of Demeter
class Order
def customer_city
@customer.city
end
end
class Customer
def city
@address.city
end
end
order = Order.new(customer: Customer.new(address: Address.new(city: "Chicago")))
city = order.customer_city # Single method call
Ruby Implementation
Ruby implements encapsulation through method visibility modifiers: public, private, and protected. Methods default to public visibility unless explicitly declared otherwise. The private keyword makes methods callable only within the class, preventing external objects from invoking them. The protected keyword allows methods to be called by instances of the same class or subclasses.
class Person
def initialize(name, ssn)
@name = name
@ssn = ssn
end
# Public method - accessible everywhere
def introduce
"My name is #{@name}"
end
# Protected method - accessible within class and subclasses
protected
def ssn_match?(other)
@ssn == other.ssn_value
end
def ssn_value
@ssn
end
# Private methods - only callable within this instance
private
def validate_ssn
@ssn.match?(/\A\d{3}-\d{2}-\d{4}\z/)
end
end
person1 = Person.new("Alice", "123-45-6789")
person2 = Person.new("Bob", "123-45-6789")
person1.introduce # Works - public
person1.ssn_match?(person2) # Error - protected method called externally
person1.validate_ssn # Error - private method called externally
Ruby's visibility modifiers affect method lookup and invocation. Private methods cannot be called with an explicit receiver, even self. This restriction forces all private method calls to use implicit self, making them clearly internal operations. Protected methods allow explicit receivers but only for objects of the same class or subclass.
class Account
def initialize(balance)
@balance = balance
end
def transfer_to(other_account, amount)
return false unless sufficient_funds?(amount)
@balance -= amount
other_account.add_funds(amount)
true
end
protected
def add_funds(amount)
@balance += amount
end
private
def sufficient_funds?(amount)
@balance >= amount
end
def display_balance
puts format_balance # Works - implicit self
puts self.format_balance # Error - cannot call private method with explicit receiver
end
def format_balance
"$#{@balance}"
end
end
Instance variables in Ruby remain private by default with no direct external access. The language provides no syntax for declaring instance variables as public. All external access to instance variables occurs through methods. This design enforces method-based encapsulation and prevents bypassing validation logic.
class Circle
def initialize(radius)
@radius = radius
end
# Getter method
def radius
@radius
end
# Setter method with validation
def radius=(new_radius)
raise ArgumentError, "Radius must be positive" if new_radius <= 0
@radius = new_radius
end
def area
Math::PI * @radius ** 2
end
end
circle = Circle.new(5)
circle.radius # 5 - calls getter method
circle.radius = 10 # Calls setter method
circle.@radius # Syntax error - no direct instance variable access
Ruby provides attr_reader, attr_writer, and attr_accessor as shortcuts for creating getter and setter methods. These methods generate standard accessor code, reducing boilerplate while maintaining encapsulation. The generated methods remain regular methods that can be overridden or enhanced.
class Product
attr_reader :name, :sku
attr_accessor :price
def initialize(name, sku, price)
@name = name
@sku = sku
@price = price
end
end
# Equivalent to:
class Product
def initialize(name, sku, price)
@name = name
@sku = sku
@price = price
end
def name
@name
end
def sku
@sku
end
def price
@price
end
def price=(value)
@price = value
end
end
Visibility modifiers in Ruby apply to subsequently defined methods until another modifier appears. This differs from languages where visibility attaches to individual method declarations. The modifier creates a scope affecting all following method definitions.
class Example
public # Everything below is public (this is the default)
def public_method_one
end
def public_method_two
end
private # Everything below is private
def private_method_one
end
def private_method_two
end
public # Switch back to public
def public_method_three
end
end
Ruby also supports inline visibility declarations using symbol arguments. This syntax applies visibility to specific methods without affecting subsequent definitions.
class Example
def method_one
end
def method_two
end
def method_three
end
private :method_two, :method_three
def method_four # Remains public
end
end
Practical Examples
Encapsulation applies to financial domain modeling where invariants must be maintained. A money class encapsulates amount and currency, preventing invalid operations like adding amounts in different currencies.
class Money
attr_reader :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def amount
@amount.round(2)
end
def +(other)
raise ArgumentError, "Cannot add different currencies" unless same_currency?(other)
Money.new(@amount + other.raw_amount, @currency)
end
def -(other)
raise ArgumentError, "Cannot subtract different currencies" unless same_currency?(other)
Money.new(@amount - other.raw_amount, @currency)
end
def ==(other)
return false unless same_currency?(other)
@amount == other.raw_amount
end
protected
def raw_amount
@amount
end
private
def same_currency?(other)
@currency == other.currency
end
end
price = Money.new(99.99, "USD")
tax = Money.new(8.00, "USD")
total = price + tax # Money.new(107.99, "USD")
euro_amount = Money.new(50, "EUR")
invalid = price + euro_amount # Raises ArgumentError
Authentication systems benefit from encapsulation by hiding password storage and comparison logic. The interface provides authentication methods without exposing password hashes or comparison algorithms.
require 'digest'
class User
attr_reader :username
def initialize(username, password)
@username = username
@password_hash = hash_password(password)
@failed_attempts = 0
@locked_until = nil
end
def authenticate(password)
return false if account_locked?
if valid_password?(password)
reset_failed_attempts
true
else
increment_failed_attempts
false
end
end
def change_password(old_password, new_password)
return false unless authenticate(old_password)
@password_hash = hash_password(new_password)
true
end
private
def hash_password(password)
Digest::SHA256.hexdigest(password)
end
def valid_password?(password)
@password_hash == hash_password(password)
end
def account_locked?
@locked_until && Time.now < @locked_until
end
def increment_failed_attempts
@failed_attempts += 1
@locked_until = Time.now + 900 if @failed_attempts >= 3 # Lock for 15 minutes
end
def reset_failed_attempts
@failed_attempts = 0
@locked_until = nil
end
end
user = User.new("alice", "secret123")
user.authenticate("wrong") # false
user.authenticate("secret123") # true
user.@password_hash # Syntax error - cannot access directly
Cache implementations use encapsulation to hide eviction strategies and storage mechanisms while providing simple get and set operations.
class LRUCache
def initialize(capacity)
@capacity = capacity
@cache = {}
@access_order = []
end
def get(key)
return nil unless @cache.key?(key)
update_access_order(key)
@cache[key]
end
def put(key, value)
if @cache.key?(key)
@cache[key] = value
update_access_order(key)
else
evict_oldest if at_capacity?
@cache[key] = value
@access_order << key
end
end
def size
@cache.size
end
private
def at_capacity?
@cache.size >= @capacity
end
def evict_oldest
oldest_key = @access_order.shift
@cache.delete(oldest_key)
end
def update_access_order(key)
@access_order.delete(key)
@access_order << key
end
end
cache = LRUCache.new(3)
cache.put("a", 1)
cache.put("b", 2)
cache.put("c", 3)
cache.get("a") # Moves "a" to end of access order
cache.put("d", 4) # Evicts "b" (oldest)
State machine implementations encapsulate state transition logic and validation, ensuring that state changes follow defined rules.
class OrderStateMachine
VALID_TRANSITIONS = {
pending: [:processing, :cancelled],
processing: [:shipped, :cancelled],
shipped: [:delivered],
delivered: [],
cancelled: []
}.freeze
attr_reader :state
def initialize
@state = :pending
@state_history = [:pending]
end
def transition_to(new_state)
raise InvalidTransitionError, "Cannot transition from #{@state} to #{new_state}" unless valid_transition?(new_state)
previous_state = @state
@state = new_state
@state_history << new_state
execute_transition_hooks(previous_state, new_state)
end
def can_transition_to?(new_state)
valid_transition?(new_state)
end
private
def valid_transition?(new_state)
VALID_TRANSITIONS[@state].include?(new_state)
end
def execute_transition_hooks(from, to)
send("on_exit_#{from}") if respond_to?("on_exit_#{from}", true)
send("on_enter_#{to}") if respond_to?("on_enter_#{to}", true)
end
def on_enter_shipped
# Send shipping notification
end
def on_enter_cancelled
# Process refund
end
end
class InvalidTransitionError < StandardError; end
order = OrderStateMachine.new
order.transition_to(:processing) # Valid
order.transition_to(:delivered) # Raises InvalidTransitionError
Design Considerations
Encapsulation decisions involve trade-offs between flexibility and protection. Strict encapsulation with minimal public interfaces creates rigid APIs that resist change but protect internal state effectively. Relaxed encapsulation with broader access exposes implementation details but provides greater flexibility for client code.
The granularity of encapsulation affects maintainability. Fine-grained encapsulation with many small classes and narrow interfaces distributes responsibility but increases the number of interactions. Coarse-grained encapsulation with fewer, larger classes consolidates logic but creates classes with more responsibilities.
Encapsulation interacts with other design principles, particularly the Single Responsibility Principle. A class with a single responsibility has a cohesive interface with related methods. Encapsulation enforces this by hiding internal collaborators and preventing external code from bypassing intended interfaces.
# Poor encapsulation - mixed responsibilities
class Report
attr_accessor :data, :format, :destination
def generate
formatted_data = format == :json ? data.to_json : data.to_xml
case destination
when :email
send_email(formatted_data)
when :file
write_file(formatted_data)
end
end
end
# Better encapsulation - separated concerns
class Report
def initialize(data, formatter, output)
@data = data
@formatter = formatter
@output = output
end
def generate
formatted_data = format_data
@output.write(formatted_data)
end
private
def format_data
@formatter.format(@data)
end
end
class JSONFormatter
def format(data)
data.to_json
end
end
class EmailOutput
def write(content)
# Send email
end
end
The decision to encapsulate collections requires careful consideration. Returning direct references to internal collections allows external code to modify the collection, breaking encapsulation. Returning copies prevents modification but impacts performance. Returning read-only proxies balances protection and performance.
class Team
def initialize
@members = []
end
# Poor - returns mutable reference
def members
@members
end
# Better - returns frozen copy
def members
@members.dup.freeze
end
# Better - returns defensive copy
def members
@members.map(&:dup)
end
# Best - provides specific operations
def add_member(member)
@members << member unless @members.include?(member)
end
def remove_member(member)
@members.delete(member)
end
def member_count
@members.size
end
def each_member(&block)
@members.each(&block)
end
end
Inheritance complicates encapsulation by creating tension between subclass flexibility and superclass encapsulation. Protected access enables subclass customization while maintaining some encapsulation. Composition often provides better encapsulation than inheritance by hiding implementation objects completely.
Encapsulation boundaries must align with system architecture. Microservices architecture applies encapsulation at the service level, hiding internal databases and data structures behind APIs. Monolithic architectures apply encapsulation at the class and module level within a single deployment unit.
Temporal encapsulation deals with state changes over time. Immutable objects provide strong temporal encapsulation by preventing state changes after construction. Mutable objects require careful design to ensure state transitions maintain invariants throughout the object's lifetime.
Common Patterns
The Tell-Don't-Ask pattern enhances encapsulation by pushing behavior into objects rather than extracting state for external manipulation. This pattern tells objects what to do rather than asking for their state and making decisions externally.
# Ask pattern - poor encapsulation
class ShoppingCart
attr_reader :items
def initialize
@items = []
end
def add_item(item)
@items << item
end
end
# External code makes decisions
cart = ShoppingCart.new
total = cart.items.sum(&:price)
discounted = total * 0.9 if cart.items.size > 10
# Tell pattern - better encapsulation
class ShoppingCart
def initialize
@items = []
end
def add_item(item)
@items << item
end
def total
raw_total = @items.sum(&:price)
apply_bulk_discount(raw_total)
end
private
def apply_bulk_discount(amount)
@items.size > 10 ? amount * 0.9 : amount
end
end
cart = ShoppingCart.new
total = cart.total # Cart handles its own calculation
The Builder pattern encapsulates complex object construction, hiding multi-step assembly and validation logic behind a fluent interface.
class EmailBuilder
def initialize
@to = []
@cc = []
@bcc = []
@subject = ""
@body = ""
@attachments = []
end
def to(*addresses)
@to.concat(addresses)
self
end
def cc(*addresses)
@cc.concat(addresses)
self
end
def subject(text)
@subject = text
self
end
def body(text)
@body = text
self
end
def attach(file)
@attachments << file
self
end
def build
validate_email
Email.new(
to: @to,
cc: @cc,
bcc: @bcc,
subject: @subject,
body: @body,
attachments: @attachments
)
end
private
def validate_email
raise "Email must have at least one recipient" if @to.empty?
raise "Email must have a subject" if @subject.empty?
end
end
class Email
def initialize(to:, cc:, bcc:, subject:, body:, attachments:)
@to = to
@cc = cc
@bcc = bcc
@subject = subject
@body = body
@attachments = attachments
end
end
email = EmailBuilder.new
.to("user@example.com")
.cc("manager@example.com")
.subject("Project Update")
.body("Status report attached")
.attach("report.pdf")
.build
The Strategy pattern encapsulates algorithms behind a common interface, allowing algorithm selection without exposing implementation details.
class PricingCalculator
def initialize(strategy)
@strategy = strategy
end
def calculate(base_price, quantity)
@strategy.calculate(base_price, quantity)
end
def strategy=(new_strategy)
@strategy = new_strategy
end
end
class RegularPricing
def calculate(base_price, quantity)
base_price * quantity
end
end
class BulkPricing
def calculate(base_price, quantity)
total = base_price * quantity
quantity >= 100 ? total * 0.8 : total
end
end
class VIPPricing
def calculate(base_price, quantity)
(base_price * quantity) * 0.75
end
end
calculator = PricingCalculator.new(RegularPricing.new)
price = calculator.calculate(10, 50) # 500
calculator.strategy = BulkPricing.new
price = calculator.calculate(10, 150) # 1200
The Null Object pattern encapsulates absence, providing an object with a defined interface that performs no operation instead of using nil checks.
class RealLogger
def log(message)
File.open("app.log", "a") do |f|
f.puts("[#{Time.now}] #{message}")
end
end
end
class NullLogger
def log(message)
# Do nothing
end
end
class Application
def initialize(logger = NullLogger.new)
@logger = logger
end
def process(data)
@logger.log("Processing started")
# Process data
@logger.log("Processing completed")
end
end
# No null checks needed
app = Application.new # Uses NullLogger
app.process(data)
app_with_logging = Application.new(RealLogger.new)
app_with_logging.process(data) # Logs to file
Common Pitfalls
Ruby's attr_accessor creates public getter and setter methods, breaking encapsulation when used without consideration. Developers often default to attr_accessor for all instance variables, exposing internal state unnecessarily.
# Pitfall - over-exposed state
class Rectangle
attr_accessor :width, :height
end
rect = Rectangle.new
rect.width = 10
rect.height = -5 # Invalid state allowed
# Better - controlled access
class Rectangle
def initialize(width, height)
self.width = width
self.height = height
end
def width
@width
end
def width=(value)
raise ArgumentError, "Width must be positive" if value <= 0
@width = value
end
def height
@height
end
def height=(value)
raise ArgumentError, "Height must be positive" if value <= 0
@height = value
end
def area
@width * @height
end
end
Returning mutable references to internal collections violates encapsulation by allowing external modification of internal state.
class Library
def initialize
@books = []
end
def books
@books # Returns mutable reference
end
def add_book(book)
@books << book
end
end
library = Library.new
library.add_book("Book 1")
library.books << "Book 2" # Bypasses add_book method
library.books.clear # Destroys internal state
# Better - return frozen copy or iterator
class Library
def initialize
@books = []
end
def books
@books.dup.freeze
end
def each_book(&block)
@books.each(&block)
end
def add_book(book)
@books << book unless @books.include?(book)
end
end
Protected methods in Ruby differ from other languages. Protected methods can be called with an explicit receiver if the receiver is of the same class, which developers familiar with Java or C++ might not expect.
class Person
def initialize(age)
@age = age
end
def older_than?(other)
@age > other.age # Can call protected method with explicit receiver
end
protected
def age
@age
end
end
person1 = Person.new(30)
person2 = Person.new(25)
person1.older_than?(person2) # Works
person1.age # Error - protected method called externally
Using send or public_send bypasses visibility restrictions, breaking encapsulation. While sometimes necessary for metaprogramming, this technique should be used sparingly.
class Account
def initialize(balance)
@balance = balance
end
private
def balance
@balance
end
end
account = Account.new(1000)
account.balance # Error - private method
account.send(:balance) # 1000 - bypasses encapsulation
account.instance_variable_get(:@balance) # 1000 - direct access
Lazy initialization in getter methods creates hidden side effects that violate the principle of least surprise. Getters should not modify state.
# Pitfall - getter with side effects
class DataStore
def data
@data ||= expensive_load_operation
end
private
def expensive_load_operation
# Load from database or API
sleep(2)
["data"]
end
end
store = DataStore.new
puts "Getting data..."
store.data # First call is slow
store.data # Subsequent calls are fast
# Better - explicit initialization
class DataStore
def initialize
@loaded = false
end
def load
return if @loaded
@data = expensive_load_operation
@loaded = true
end
def data
raise "Data not loaded - call load first" unless @loaded
@data
end
private
def expensive_load_operation
sleep(2)
["data"]
end
end
Class variables (@@variable) break encapsulation in inheritance hierarchies by sharing state across all subclasses and instances.
class Counter
@@count = 0
def increment
@@count += 1
end
def count
@@count
end
end
class SpecialCounter < Counter
end
counter1 = Counter.new
counter2 = SpecialCounter.new
counter1.increment
counter2.increment
counter1.count # 2 - shared state
counter2.count # 2 - shared state
# Better - use class instance variables
class Counter
@count = 0
class << self
attr_accessor :count
end
def increment
self.class.count += 1
end
def count
self.class.count
end
end
class SpecialCounter < Counter
@count = 0
end
Reference
Visibility Modifiers
| Modifier | Accessible From | Explicit Receiver | Inheritance |
|---|---|---|---|
| public | Everywhere | Allowed | Inherited as public |
| protected | Same class and subclasses | Allowed for same class instances | Inherited as protected |
| private | Same instance only | Not allowed (not even self) | Inherited as private |
Access Control Methods
| Method | Purpose | Example |
|---|---|---|
| attr_reader | Creates getter method | attr_reader :name |
| attr_writer | Creates setter method | attr_writer :name |
| attr_accessor | Creates getter and setter | attr_accessor :name |
| private | Sets following methods as private | private |
| protected | Sets following methods as protected | protected |
| public | Sets following methods as public | public |
Encapsulation Patterns
| Pattern | Purpose | Key Benefit |
|---|---|---|
| Tell-Don't-Ask | Push behavior into objects | Reduces coupling |
| Builder | Encapsulate complex construction | Simplifies object creation |
| Strategy | Encapsulate algorithms | Enables algorithm selection |
| Null Object | Encapsulate absence | Eliminates nil checks |
| Template Method | Encapsulate algorithm structure | Allows customization points |
Common Violations
| Violation | Problem | Solution |
|---|---|---|
| Public attr_accessor for all variables | Exposes internal state | Use attr_reader or custom setters with validation |
| Returning mutable collections | Allows external modification | Return frozen copies or iterators |
| Using send to bypass visibility | Breaks access control | Respect visibility boundaries |
| Lazy initialization in getters | Hidden side effects | Explicit initialization methods |
| Class variables in hierarchies | Shared state across subclasses | Use class instance variables |
Design Guidelines
| Guideline | Description | Rationale |
|---|---|---|
| Principle of Least Privilege | Default to most restrictive access | Minimizes coupling |
| Law of Demeter | Only talk to immediate neighbors | Reduces dependencies |
| Tell-Don't-Ask | Command objects instead of querying state | Improves encapsulation |
| Immutability When Possible | Prevent state changes after construction | Eliminates temporal coupling |
| Defensive Copying | Copy mutable inputs and outputs | Protects internal state |
Method Visibility Declaration Syntax
| Syntax | Effect | Scope |
|---|---|---|
| private | Affects all following methods | Until next modifier |
| private def method_name | Declares single method private | Single method |
| private :method1, :method2 | Makes specific methods private | Listed methods |
| class << self; private :method; end | Private class method | Class method |