Value Objects in Ruby

· 7 min read · Updated March 31, 2026 · intermediate
ruby value-objects design-patterns guides

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:

  1. Immutable — once created, the data does not change.
  2. Equality by value — two instances with the same data are equal.
  3. 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:

  1. Freeze all state in initialize.
  2. Override == and eql? to compare values.
  3. Implement hash so 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

FeatureStructOpenStructDataPlain class
ImmutabilityOptional (freeze: true)NoYes (default)Manual
Value equalityYesNoYesManual
Value hashingYesNoYesManual
Pattern matchingYesNoYesManual
Dynamic attributesNoYesNoNo
PerformanceFastSlowFastDepends
Ruby versionAllAll3.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