DEV Community

Surya
Surya

Posted on

1 1

Force immutability with Ruby

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

👉 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!
Enter fullscreen mode Exit fullscreen mode

👉 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!
Enter fullscreen mode Exit fullscreen mode

👉 If an object changes after being cached, the cache becomes inconsistent.


🚀 Why Immutability Helps

  1. Predictability – Immutable objects make code easier to reason about because their state is fixed.
  2. Thread Safety – Multiple threads can access immutable objects without risk of race conditions.
  3. Simplicity – No need to track object state across different contexts.
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

👉 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
Enter fullscreen mode Exit fullscreen mode

👉 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
Enter fullscreen mode Exit fullscreen mode

👉 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

  1. Ruby is mutable by default, which can lead to unpredictable state changes, race conditions, and bugs.
  2. Immutability creates more predictable and maintainable code.
  3. Use freeze to enforce immutability.
  4. 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)