Immutability is one of the most powerful tools for building predictable, maintainable, and thread-safe applications. However, Ruby is an inherently mutable language — objects are designed to be modified freely, which can lead to subtle bugs and complex state management issues.
In this post, we'll explore:
✅ Why immutability matters
✅ The problems with mutable objects
✅ How to enforce immutability in Ruby using freeze
and other patterns
✅ Practical examples to make your Ruby code more robust
🧠 What is Immutability?
An immutable object is an object whose state cannot be changed after it is created. In other words:
- You can create it.
- You can read from it.
- But you can’t modify it.
This contrasts with mutable objects, which allow their state to change after initialization.
🚨 The Problem with Mutable Objects
In Ruby, objects are mutable by default:
person = { name: "John", age: 30 }
person[:age] = 31
puts person # {:name=>"John", :age=>31}
Here’s why this can become a problem:
1. Unintended Side Effects
If an object is passed around and modified by multiple parts of a program, unexpected changes can creep in:
def update_age(person)
person[:age] += 1
end
person = { name: "John", age: 30 }
update_age(person)
puts person # {:name=>"John", :age=>31}
👉 The function update_age
modifies the original object, which could lead to unpredictable behavior if other parts of the code depend on the original state.
2. Thread Safety Issues
Mutable objects can create race conditions in multithreaded environments:
person = { name: "John", age: 30 }
threads = 10.times.map do
Thread.new do
person[:age] += 1
end
end
threads.each(&:join)
puts person # Age is unpredictable!
👉 Since person
is shared across threads and modified concurrently, the final value of age
is unpredictable and inconsistent.
3. Caching Issues
Mutable objects can make caching tricky since the same object might have a different state between accesses:
person = { name: "John", age: 30 }
cache = { person[:name] => person }
person[:age] = 31
puts cache["John"][:age] # 31 — Cache consistency is broken!
👉 If an object changes after being cached, the cache becomes inconsistent.
🚀 Why Immutability Helps
- Predictability – Immutable objects make code easier to reason about because their state is fixed.
- Thread Safety – Multiple threads can access immutable objects without risk of race conditions.
- Simplicity – No need to track object state across different contexts.
- Functional-Style Code – Encourages a functional approach (pure functions, no side effects).
🔒 How to Force Immutability in Ruby
Ruby doesn’t have native support for immutable objects (like final
in Java), but you can simulate it using patterns and language features like freeze
.
✅ 1. Use freeze
to Lock Objects
The simplest way to enforce immutability is by calling freeze
on an object:
person = { name: "John", age: 30 }.freeze
person[:age] = 31 # => FrozenError (can't modify frozen Hash)
When you freeze
an object:
- All fields become read-only.
- Any attempt to modify it will raise a
FrozenError
.
👉 Example with Custom Class:
You can freeze
the instance inside the initializer to protect it:
class Person
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
freeze
end
end
person = Person.new("John", 30)
person.age = 31 # => NoMethodError (undefined method)
Key Behavior:
-
@name
and@age
are accessible but read-only. - The object state is now immutable.
✅ 2. Use Struct
with freeze
Struct
in Ruby is useful for defining simple data objects. If you freeze
a struct, it becomes immutable:
Person = Struct.new(:name, :age) do
def initialize(*args)
super(*args)
freeze
end
end
person = Person.new("John", 30)
person.age = 31 # => FrozenError (can't modify frozen Struct)
👉 Struct
combined with freeze
makes it easy to create immutable data objects.
✅ 3. Use Value Objects
to Create Immutable Objects
You can model state as value objects — objects that represent data without behavior — and make them immutable:
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
freeze
end
end
money = Money.new(100, 'USD')
money.amount = 200 # => NoMethodError
👉 Value objects are ideal for representing things like money, dates, and coordinates.
✅ 4. Return New Instances Instead of Mutating State
Instead of modifying an object, return a new one with updated state:
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
freeze
end
def add(other)
Money.new(amount + other.amount, currency)
end
end
money1 = Money.new(100, 'USD')
money2 = money1.add(Money.new(50, 'USD'))
puts money1.amount # 100
puts money2.amount # 150
👉 The original object remains unchanged, and the state change is represented by creating a new instance.
🧪 When Should You Use Immutability?
✅ Use immutability when:
- The object represents fixed state (e.g., configuration, settings).
- You want to avoid unintended side effects.
- You need thread safety.
- You’re building functional-style code.
❌ Don’t use immutability when:
- Performance is critical and object creation is expensive.
- The object is designed for short-term mutation.
🎯 Summary
- Ruby is mutable by default, which can lead to unpredictable state changes, race conditions, and bugs.
- Immutability creates more predictable and maintainable code.
- Use
freeze
to enforce immutability. - Design state as value objects and return new instances instead of modifying existing ones.
🔥 Final Thought
Immutability in Ruby might feel unnatural at first, but once you start working with immutable objects, you’ll notice how much easier it is to reason about your code. Give it a try — your future self (and your bug tracker) will thank you!
Top comments (0)