Value Objects in Ruby
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.
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)
# 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.
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>
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>)
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.
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.
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.
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
A simple rule: always derive hash from the same attributes you compare in ==.
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)>
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.
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
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