Ruby value objects: immutable data with value equality
Value objects are one of the most useful patterns in Ruby, and most Rubyists use them daily without thinking about it. A Date, a Rational, or even a plain Integer, all of these are value objects. This guide shows you what makes a value object, how to build one, and when to reach for it over a regular class.
Intro context
Value objects matter because many parts of a Ruby program care about data, not identity. Money amounts, coordinates, dates, and colors are useful examples: if the data matches, the object should behave the same way no matter which instance you hold. That is different from an entity like a user or order, where identity and state both matter.
The practical win is that value objects make code easier to compare, cache, and pass around. They are also easier to test because the expected result is usually just a new object with predictable fields. If you already use Struct or small plain objects, value objects are the next step when you want those objects to behave like stable values instead of mutable records.
What is a value object?
A value object is an object whose equality is determined by the data it holds, not by its identity in memory. Two value objects with the same data are interchangeable, it doesn’t matter which instance you use.
# Regular object — equality by identity
class CartItem
attr_accessor :name, :price
def initialize(name, price)
@name = name
@price = price
end
end
item_a = CartItem.new("Coffee", 4.50)
item_b = CartItem.new("Coffee", 4.50)
item_a == item_a # => true
item_a == item_b # => false (different objects in memory)
That comparison shows why a regular class does not behave like a value out of the box. Two cart items with the same name and price are treated as different objects because Ruby compares them by identity, not by content. To fix this, you need to override == and hash so the class compares field values instead of memory addresses:
# Value object — equality by value
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def ==(other)
self.class == other.class &&
@amount == other.amount &&
@currency == other.currency
end
alias eql? ==
def hash
[@amount, @currency].hash
end
end
coffee_a = Money.new(4.50, "USD")
coffee_b = Money.new(4.50, "USD")
coffee_a == coffee_b # => true
The three hallmarks of a value object are:
- Immutable, once created, the data does not change.
- Equality by value, two instances with the same data are equal.
- No side effects, operations return new instances rather than mutating existing ones.
Ruby gives you several ways to build value objects. Each has different trade-offs.
The right choice depends on how much control you need. If your object only needs fields and equality, Struct or Data is often enough. If you need more custom behavior, a plain class with frozen state gives you the same value semantics with more room to grow.
Struct
Struct creates a class with named accessors, a convenient initializer, and value-based equality out of the box.
Point = Struct.new(:x, :y)
p1 = Point.new(3, 4)
p2 = Point.new(3, 4)
p1 == p2 # => true
p1.x # => 3
p1[:x] # => 3
p1 # => #<struct Point x=3, y=4>
That single line of Struct.new gives you readable accessors and equality for free without any ceremony. The struct automatically compares by value, so two points with the same coordinates test as equal in hashes and sets without any extra work on your part.
Struct is frozen by default in Ruby 3.x, which makes it a solid starting point for immutable value objects:
Point = Struct.new(:x, :y, :freeze: true)
p = Point.new(3, 4)
p.x = 5 # => FrozenError (undefined method 'x=' for #<struct Point x=3, y=4>)
The freeze: true flag is important because mutability is the biggest threat to value semantics. If the caller can change a field after construction, two objects that were equal a moment ago might no longer be equal, which breaks hash lookups and makes the code harder to reason about.
Structs automatically implement ==, eql?, and hash based on their fields, so two structs with the same values are equal:
Point = Struct.new(:x, :y, :freeze: true)
p1 = Point.new(1, 2)
p2 = Point.new(1, 2)
p1 == p2 # => true
{p1 => "origin"}[p2] # => "origin" — works because hash matches
A limitation of Struct is that it is still a Struct subclass, so you cannot add truly private state or custom initialisation logic without overriding initialize. For simple data carriers, though, it is hard to beat.
In practice, Struct is the easiest way to get a value object into a codebase quickly. It gives you a familiar Ruby object, but with equality and hashing behavior that already match the value-object model.
OpenStruct
OpenStruct creates an object that accepts any attribute dynamically:
require "ostruct"
person = OpenStruct.new(name: "Alice", age: 30)
person.name # => "Alice"
person.city = "London"
person.city # => "London"
OpenStruct is slower than Struct because it uses a hash internally. It also does not provide equality or hashing by default, so two OpenStruct instances with the same data are not equal:
a = OpenStruct.new(x: 1, y: 2)
b = OpenStruct.new(x: 1, y: 2)
a == b # => false
For these reasons, OpenStruct is best suited to quick prototyping or situations where you genuinely need dynamic attributes. For anything representing a real domain concept, Struct or Data is a better fit.
That distinction matters because value objects are supposed to be stable. If the shape can change at runtime, the object stops feeling like a value and starts behaving like a bag of state. When the schema is fixed and known ahead of time, the stricter approach always wins for correctness and performance.
Data (Ruby 3.4+)
Data.define is the newest way to build value objects in Ruby. It was introduced in Ruby 3.4 and is specifically designed for this purpose.
Money = Data.define(:amount, :currency)
dollar = Money.new(amount: 1.50, currency: "USD")
dollar.amount # => 1.5
dollar.currency # => "USD"
Data-defined objects are frozen by default and implement equality, hashing, and pattern matching out of the box:
m1 = Money.new(amount: 10, currency: "EUR")
m2 = Money.new(amount: 10, currency: "EUR")
m1 == m2 # => true
m1.frozen? # => true
{m1 => "found"}[m2] # => "found"
# Pattern matching support
case money
in Money[amount:, currency: "USD"]
"US dollars"
in Money[amount:, currency:]
"Other: #{currency}"
end
Unlike Struct, Data.define does not allow you to set attributes after construction, and it does not expose a public initialize you can override. To add validation, pass a block to Data.define:
Temperature = Data.define(:value, :unit) do
def initialize(value, unit)
raise ArgumentError, "unit must be C or F" unless %w[C F].include?(unit)
super
end
def to_fahrenheit
case unit
when "F" then value
when "C" then value * 9.0 / 5 + 32
end
end
end
temp = Temperature.new(100, "C")
temp.to_fahrenheit # => 212.0
Data.define is the cleanest and most intentional choice for new value objects in Ruby 3.4 and above.
It is especially appealing when you want something that behaves like a modern, immutable record without writing much boilerplate. The class shape is fixed, the values are frozen, and the semantics are obvious to anyone reading the code.
Implementing value objects from scratch
Sometimes you need more control than Struct or Data offers. Building a value object by hand is straightforward as long as you follow the rules:
- Freeze all state in
initialize. - Override
==andeql?to compare values. - Implement
hashso instances can be used as hash keys.
class Coordinate
attr_reader :lat, :lon
def initialize(lat, lon)
@lat = lat
@lon = lon
freeze
end
def ==(other)
self.class == other.class &&
@lat == other.lat &&
@lon == other.lon
end
alias eql? ==
def hash
[@lat, @lon].hash
end
def to_s
"(#{@lat}, #{@lon})"
end
end
a = Coordinate.new(51.5074, -0.1278)
b = Coordinate.new(51.5074, -0.1278)
a == b # => true
{a => "London"}[b] # => "London"
a.frozen? # => true
If you skip hash, your objects will still compare equal with ==, but they will not work correctly as hash keys or set members, a subtle bug that can cause real problems in production.
The hash Contract
When two objects are equal (a == b is true), they must have the same hash value. Ruby relies on this for hash-based collections:
class BadValue
attr_reader :x
def initialize(x)
@x = x
freeze
end
def ==(other)
self.class == other.class && @x == other.x
end
# Forgot to implement hash!
end
bv1 = BadValue.new(1)
bv2 = BadValue.new(1)
{bv1 => "ok"}[bv2] # => nil — lookup fails even though they are equal
That nil result is the silent bug that bites people who implement == without hash. The object looks equal in inspection, but hash-based collections treat it as a completely different entry. That mismatch can cause duplicate records, missed cache hits, and confusing test failures that are hard to trace back to the missing method.
A simple rule: always derive hash from the same attributes you compare in ==.
That rule keeps your object safe to use in hashes and sets. It also makes equality easier to reason about, because the same fields drive both comparison and lookup behavior.
def hash
[@lat, @lon].hash
end
When to use value objects
Value objects shine for domain concepts that are defined by their attributes rather than a unique identity.
Money is the canonical example. £10 is £10 regardless of which Money instance represents it. Adding two amounts produces a new amount, the originals are unchanged.
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = BigDecimal(amount)
@currency = currency
freeze
end
def +(other)
raise TypeError, "Currency mismatch" unless @currency == other.currency
Money.new(@amount + other.amount, @currency)
end
def ==(other)
self.class == other.class &&
@amount == other.amount &&
@currency == other.currency
end
alias eql? ==
def hash
[@amount, @currency].hash
end
end
price = Money.new("10.00", "GBP") + Money.new("5.50", "GBP")
price.amount # => #<BigDecimal:7f8a3c, '15.5',3 (12 digits)>
Money objects work naturally with operators because each operation returns a new, frozen instance. The original objects never change, which means you can safely pass them into methods and use them as hash keys without worrying about accidental mutation downstream.
Dates and ranges are another natural fit. Date.today + 1 returns a new date; it does not change today. Coordinates, colours, measurements, anything that makes sense as “the same if the data is the same” is a good candidate.
In contrast, entities like a User, an Order, or a Session should generally not be value objects. They have identity, two orders with the same data are still different orders. For entities you track by ID; for value objects you compare by content.
Forward link
Once you are comfortable with value equality, the natural next step is to compare how Ruby lets you store and transport that data. The Struct guide and the hashes guide are good companions because they show the other common ways Ruby code moves structured data around.
Frozen objects and value objects
Ruby provides Object#freeze to make objects immutable. A frozen object cannot be modified:
point = [3, 4].freeze
point[0] = 5 # => FrozenError: can't modify frozen Array
Freezing is not the same as being a value object, but it is a prerequisite for most value objects. A frozen regular object still compares by identity unless you override ==:
a = [1, 2].freeze
b = [1, 2].freeze
a == b # => true (arrays have value equality, but this is a special case)
a.object_id == b.object_id # => false
For your own classes, freeze in initialize and implement value equality to get true value object behaviour.
class RGB
attr_reader :r, :g, :b
def initialize(r, g, b)
@r, @g, @b = r, g, b
freeze
end
def ==(other)
self.class == other.class &&
@r == other.r && @g == other.g && @b == other.b
end
alias eql? ==
def hash
[@r, @g, @b].hash
end
end
The RGB class follows the same three-step recipe: freeze in initialize, override == and eql? for value comparison, and implement hash from the same fields. Once you have practised the pattern a few times, it becomes quick to write and easy to recognise when reading unfamiliar code.
Comparison of approaches
| Feature | Struct | OpenStruct | Data | Plain class |
|---|---|---|---|---|
| Immutability | Optional (freeze: true) | No | Yes (default) | Manual |
| Value equality | Yes | No | Yes | Manual |
| Value hashing | Yes | No | Yes | Manual |
| Pattern matching | Yes | No | Yes | Manual |
| Dynamic attributes | No | Yes | No | No |
| Performance | Fast | Slow | Fast | Depends |
| Ruby version | All | All | 3.4+ | All |
For Ruby 3.4 and above, prefer Data.define for new value objects. For older Ruby or when you need more customisation, Struct.new(freeze: true) with Data.define-style equality gets you most of the way there.
See Also
- /guides/ruby-struct-guide/ — A deeper look at Ruby’s Struct and when to use it
- /guides/ruby-blocks-procs-lambdas/ — Related Ruby concepts that pair well with value objects
- /guides/ruby-symbols-deep-dive/ — Symbols, which are Ruby’s canonical immutable value type