Jun 11, 2026
Value Objects in Rails: Money, Coordinates, and the Quiet Refactor
Strings and integers are fine until they're not. Here's how I introduce value objects to a Rails app — when a PORO is enough, when composed_of fits, and when to reach for money-rails.
There’s a moment in every Rails app where a column called price_cents (integer) is being passed around with a currency column (string), and every method that touches them re-implements the same five lines of formatting, conversion, and comparison. That’s the moment for a value object.
A value object is a small, immutable Ruby object whose identity is its value. Two Money(10, "USD") instances are equal because their amount and currency are equal — not because they’re the same Ruby object. Once you have one, the formatting code stops being scattered, and the type system (such as it is) starts doing useful work.
The minimum viable value object
You don’t need a gem. You need a frozen PORO with == and hash defined:
# illustrative
class Money
attr_reader :cents, :currency
def initialize(cents, currency)
@cents = Integer(cents)
@currency = currency.to_s.upcase
freeze
end
def +(other)
raise CurrencyMismatch unless currency == other.currency
self.class.new(cents + other.cents, currency)
end
def ==(other)
other.is_a?(Money) && cents == other.cents && currency == other.currency
end
alias_method :eql?, :==
def hash
[cents, currency].hash
end
def to_s
"#{format("%.2f", cents / 100.0)} #{currency}"
end
end
Five things to notice:
- Frozen in
initialize. Mutation is the whole bug class value objects exist to prevent. - Operations return new instances.
+doesn’t mutate; it constructs. ==andhashtogether. If you only define==, the object behaves wrong as a Hash key. This is the most common mistake I see.eql?aliased to==. Required for Hash key equality.- No setters, no
attr_writer, noupdate_attributeenergy. If the value needs to change, you assign a new value object to the parent.
That’s a value object. The rest is convenience.
Other things that should be value objects
Once you start looking, you find them everywhere:
PhoneNumber— wraps E.164 string, knows how to format for display vs storage, validates on construction.Email— normalizes (lowercase, strip), validates format, exposesdomain.Coordinates—(lat, lng)pair withdistance_to(other).DateRange—(start, finish)withoverlaps?,covers?,duration.Slug— wraps a string with parameterization rules.
The tell is always the same: you have two or three primitives that travel together, and the methods that operate on them are spread across three controllers and a service. Bundle them.
Persisting value objects: three options
The PORO above has no idea how to save itself. Rails gives you three integration patterns, in increasing order of magic.
1. Manual: convert at the boundary
For one-off cases:
# illustrative
class Order < ApplicationRecord
def total
Money.new(total_cents, currency)
end
def total=(money)
self.total_cents = money.cents
self.currency = money.currency
end
end
Two columns in, one value object out. No metaprogramming. Readable and grep-able. I default here when one model has one value object.
2. composed_of
Rails has had a built-in for this since forever. It’s well-documented at api.rubyonrails.org:
# illustrative
class Order < ApplicationRecord
composed_of :total,
class_name: "Money",
mapping: [%w[total_cents cents], %w[currency currency]],
constructor: ->(cents, currency) { Money.new(cents, currency) }
end
Now order.total returns a Money, order.total = Money.new(...) writes both columns, and Order.where(total: Money.new(1000, "USD")) works for finders.
composed_of is older than I’d like to admit and gets used less often than it deserves. The documentation calls it out as appropriate for “objects which act as value objects” — exactly this. The downside: it’s a little surprising to readers who haven’t seen it before. The convention isn’t widespread.
3. Custom Active Record types
The Rails 5+ way is the ActiveModel::Type interface. You write a type class that handles the cast/serialize/deserialize round-trip, then attach it with attribute:
# illustrative
class MoneyType < ActiveRecord::Type::Value
def cast(value)
return value if value.is_a?(Money)
return nil if value.blank?
# cast from a hash payload, a primitive, etc.
Money.new(value[:cents], value[:currency])
end
def serialize(value)
return nil unless value.is_a?(Money)
{ cents: value.cents, currency: value.currency }
end
end
class Order < ApplicationRecord
attribute :total, MoneyType.new
end
This pattern shines when the database column is JSON or JSONB and you want the value object to round-trip through it. For two-column patterns like (cents, currency), composed_of is shorter.
When to reach for money-rails
For monetary values specifically, money-rails is the battle-tested choice. It wraps the money gem with Rails integration:
# illustrative
class Order < ApplicationRecord
monetize :total_cents, as: :total
end
You get:
- Full currency awareness (formatting per locale, exchange rates if you wire one up)
- Arithmetic with currency mismatch errors
- Form helpers, validators, JSON serialization
- A
Moneyobject that’s been refined by years of bug reports
I default to money-rails for any app that handles real money. Rolling your own Money is a fine teaching exercise; using a gem with a deep test suite for production billing is the adult choice.
The objection: “this is overkill”
I get this one a lot. Two responses.
Short term, yes. Replacing order.total_cents / 100.0 with order.total.format is the same line of code in a different shape. You haven’t won anything yet.
Long term, no. The win shows up the second time you change something. When you add a second currency, when you add tax, when you add discounts — the value object has one place to absorb the change. The two-column pattern has a dozen places, and you’ll find them by grepping for _cents and praying.
Value objects don’t justify themselves on the day you introduce them. They justify themselves the third time you’d otherwise have copy-pasted formatting logic. By then it’s too late to introduce them cheaply, which is why I tend to introduce them slightly before I “need” to.
The shape that scales is: primitive in the database, value object in the domain, primitive again at the API boundary. The middle layer is where the logic lives.