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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Save Money When You Don’t Know Your Taxes

You get paid, you feel good… then suddenly remember taxes exist. And now you’re wondering how much of that money is actually yours.

Read more

When Headcount Freezes Hit — How Hong Kong Tech Teams Keep Shipping With Remote Contractors

A headcount freeze doesn't mean the roadmap pauses. Hong Kong startups are finding ways to keep backend work moving without adding permanent staff.

Read more

How Bureaucracy Slows Down Deployment

Ever felt like your code is ready to ship, but approvals and forms keep piling up? Bureaucracy might be protecting processes—but it’s also throttling productivity.

Read more

Clear Acceptance Criteria in Backend Development

Clear acceptance criteria define exactly when a backend deliverable is considered complete. By setting measurable standards for performance, testing, and reliability, both the client and developer can verify the result with objective benchmarks.

Read more