Value Objects in Ruby — When and Why I Reach for Them
by Eric Hanson, Backend Developer at Clean Systems Consulting
The smell that precedes them
You notice it in method signatures first. A function that takes amount, currency as two separate arguments. A model with latitude and longitude columns that always get passed around together but are never encapsulated together. An email address that's validated with a regex in three different places because there's no canonical home for that logic.
These are primitive obsession symptoms — domain concepts that exist as raw strings, floats, and integers instead of as objects that understand what they represent. The fix is a value object: an immutable object defined entirely by its attributes, with no identity beyond those attributes.
What makes something a value object
Two properties distinguish value objects from regular objects:
Equality is by value, not identity. Two Money objects representing $10.00 USD are equal even if they're different instances. Two User objects are only equal if they're the same row. This is the core distinction — value objects have no meaningful identity beyond their content.
Immutability. A value object doesn't change. You don't call price.amount = 20. You create a new Money object. Immutability is what makes value objects safe to share, cache, and pass freely without defensive copying.
A concrete implementation
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency)
raise ArgumentError, "amount must be numeric" unless amount.is_a?(Numeric)
raise ArgumentError, "currency must be an ISO 4217 code" unless currency.match?(/\A[A-Z]{3}\z/)
@amount = amount.freeze
@currency = currency.freeze
freeze
end
def +(other)
assert_same_currency!(other)
Money.new(amount + other.amount, currency)
end
def *(factor)
Money.new(amount * factor, currency)
end
def <=>(other)
assert_same_currency!(other)
amount <=> other.amount
end
def ==(other)
other.is_a?(Money) && amount == other.amount && currency == other.currency
end
alias eql? ==
def hash
[amount, currency].hash
end
def to_s
"#{currency} #{'%.2f' % amount}"
end
private
def assert_same_currency!(other)
raise ArgumentError, "currency mismatch: #{currency} vs #{other.currency}" unless currency == other.currency
end
end
Several things worth noting:
freeze on initialization makes the object truly immutable — any attempt to modify @amount or @currency after construction raises a FrozenError. Calling freeze on the primitive values before assigning them handles the case where a mutable string is passed in.
hash is defined alongside ==. In Ruby, objects used as hash keys must implement both consistently — two equal objects must have the same hash. The default hash is identity-based. If you override == without overriding hash, your value objects will behave incorrectly as hash keys and in sets.
eql? delegates to ==. Ruby's Hash uses eql? for key comparison, not ==. Getting this wrong means {Money.new(10, "USD") => "ten"}[Money.new(10, "USD")] returns nil instead of "ten".
Persistence with ActiveRecord
Value objects and ActiveRecord don't fit naturally — the ORM wants columns, not objects. Two approaches work well depending on complexity.
Composed attributes with composed_of:
class Order < ApplicationRecord
composed_of :total,
class_name: "Money",
mapping: [%w[total_amount amount], %w[total_currency currency]]
end
composed_of maps multiple columns to a single value object. order.total returns a Money instance composed from total_amount and total_currency. Assignment is handled automatically: order.total = Money.new(50_00, "USD") writes both columns.
The limitation: composed_of is read and write, but querying through the value object isn't supported. Order.where(total: Money.new(50_00, "USD")) doesn't work. You query the raw columns directly.
Attribute serialization for single-column values:
class EmailAddress
attr_reader :address
def initialize(address)
raise ArgumentError, "invalid email" unless address.match?(URI::MailTo::EMAIL_REGEXP)
@address = address.downcase.freeze
freeze
end
def ==(other)
other.is_a?(EmailAddress) && address == other.address
end
def hash = address.hash
alias eql? ==
def to_s = address
end
# In the model
class User < ApplicationRecord
attribute :email, :string
def email
EmailAddress.new(super) if super
end
def email=(value)
super(value.is_a?(EmailAddress) ? value.to_s : value)
end
end
This is manual but explicit. For a cleaner interface, define a custom ActiveRecord attribute type:
class EmailAddressType < ActiveRecord::Type::String
def cast(value)
return nil if value.nil?
value.is_a?(EmailAddress) ? value : EmailAddress.new(value.to_s)
end
def serialize(value)
value&.to_s
end
end
ActiveRecord::Type.register(:email_address, EmailAddressType)
class User < ApplicationRecord
attribute :email, :email_address
end
Now user.email always returns an EmailAddress object, user.email = "alice@example.com" stores correctly, and the conversion is centralized in one place.
Data — the Ruby 3.2 shortcut
Ruby 3.2 introduced Data, a built-in for defining simple immutable value objects:
Coordinate = Data.define(:latitude, :longitude)
point = Coordinate.new(latitude: 51.5, longitude: -0.1)
point.latitude # => 51.5
point.frozen? # => true
point == Coordinate.new(latitude: 51.5, longitude: -0.1) # => true
Data.define gives you immutability, structural equality, and a clean constructor for free. It's appropriate for value objects with no behavior beyond holding values — coordinates, color channels, date ranges. The moment you need arithmetic, validation, or custom formatting, a hand-rolled class is cleaner.
Struct is the older alternative. It's mutable by default (Struct.new(:x, :y, keyword_init: true)), which makes it a poor fit for value objects unless you call freeze explicitly. Data is the better default for new code targeting Ruby 3.2+.
When to reach for one
The practical signal is repetition of related primitives. When you see the same two or three values always traveling together — passed as a group, validated together, formatted together — that group is a concept your domain hasn't named yet.
Specific triggers:
- A method takes more than one primitive that represent a single concept (
latitude, longitude,amount, currency,start_date, end_date) - The same validation logic for a value appears in multiple places
- You're formatting or deriving values from the same raw data in multiple classes
- A primitive is being passed through several layers to reach the one method that actually uses it — the object it represents should carry its own behavior
What value objects are not: a wrapper around every string in the system. Username and ProductName as value objects add ceremony without payoff unless they carry real validation or behavior. The question is whether the type encapsulates logic that currently lives elsewhere, not whether wrapping a string is philosophically correct.
The hash and eql? checklist
Every time you write a value object, verify these before shipping:
a = Money.new(10_00, "USD")
b = Money.new(10_00, "USD")
a == b # must be true
a.eql?(b) # must be true
a.hash == b.hash # must be true
h = { a => "ten dollars" }
h[b] # must return "ten dollars", not nil
If the last line returns nil, hash is not consistent with ==. That bug surfaces late, in places that are hard to trace, because it only appears when the object is used as a hash key — which is often exactly where value objects end up.