SRP Series · 7 of 7
Value Objects
Where the pattern comes from, why concepts like Money and DateRange keep escaping as scattered methods on models, and how Rails' own ActiveSupport::Duration shows the pattern at its best.
Where this rule comes from
The term "value object" comes from Domain-Driven Design, Eric Evans' 2003 book. An entity (like a User) has an identity that persists across changes — Alice with id 42 is Alice even if her email and her name change. A value object has no identity; it is defined entirely by its attributes. A 10-dollar bill is interchangeable with any other 10-dollar bill. Two coordinates with the same latitude and longitude are the same point. Two date ranges with the same start and end are the same range. Equality is by value, not by reference.
In Rails terms, entities are usually ActiveRecord models. Value objects are usually plain Ruby classes. They do not have database tables of their own (or, when they do, they appear as columns inside an entity's row, not as their own table). They are immutable. They implement == by comparing attributes. They often include Comparable. They have methods that return new value objects, not methods that mutate the existing one.
Rails has used value objects since the very first version, even before the pattern had a name in the community. The earliest API for them was composed_of, added to ActiveRecord around 2005. Rails 5 introduced the Attribute API in 2016, a much better way to attach value-typed attributes to a model. Rails core itself ships a number of value objects you use every day: ActiveSupport::Duration, ActiveSupport::TimeWithZone, the path objects in Pathname. The pattern is everywhere; most Rails developers use value objects without naming them.
The SRP rule the pattern enforces: a small immutable concept like Money, Coordinates, or DateRange has its own behavior that does not belong on the entity that happens to store it. The Order model owns "what is an order in our database." A Money value object owns "how do two amounts of money behave when you add them, compare them, format them, or check whether they are negative." Two responsibilities, two classes.
The anti-pattern
Picture an e-commerce app. The team stored prices in cents on the Order table from day one, the way every Rails app since 2008 has. The product team kept asking for money-related features: "show the price in the user's currency," "compare to the previous month's total," "format with the right decimal separator." Each request landed as a method on Order:
class Order < ApplicationRecord
# column: total_cents, currency
def total_formatted
case currency
when "USD" then "$#{format("%.2f", total_cents / 100.0)}"
when "EUR" then "€#{format("%.2f", total_cents / 100.0)}"
when "JPY" then "¥#{total_cents}"
else "#{total_cents / 100.0} #{currency}"
end
end
def total_in_user_currency(rate)
converted_cents = (total_cents * rate).round
# ... 8 lines of currency-formatting logic again, slightly different
end
def greater_than_previous?(previous_order)
return false unless currency == previous_order.currency
total_cents > previous_order.total_cents
end
def negative_total?
total_cents < 0
end
# ... 12 more methods that all live on Order but are about money,
# not about orders
end Read it. Order now has fifteen methods about money, all of which would apply equally to Invoice, Refund, Subscription, Payout, and every other model in the app that has a price. The formatting logic for Yen vs Dollars is going to be duplicated across all of them. The "what currencies can we compare safely" check is going to drift between models. A single bug fix to currency formatting will need to land in a dozen places.
The deeper problem is that money is its own concept, with its own rules, and Order is the wrong place to put those rules. Two amounts in different currencies cannot be compared without an exchange rate. Negative zero is the same as positive zero. The smallest representable unit depends on the currency (cents for USD, the whole yen for JPY). None of those are Order rules; they are money rules. Putting them on Order means every model that handles money is reading and re-implementing the same set of rules.
How a value object solves it
A Money value object lives in its own file and captures everything that is true about money, with no knowledge of orders, invoices, or any other entity:
# app/models/money.rb (or app/value_objects/money.rb)
# Original illustrative code — every real app would extend this
# substantially. The Money gem (RubyMoney) is the canonical version
# of this class.
class Money
include Comparable
attr_reader :cents, :currency
def initialize(cents, currency)
@cents = Integer(cents)
@currency = currency.to_s.upcase.freeze
freeze
end
def +(other)
raise CurrencyMismatch unless currency == other.currency
Money.new(cents + other.cents, currency)
end
def -(other)
raise CurrencyMismatch unless currency == other.currency
Money.new(cents - other.cents, currency)
end
def <=>(other)
return nil unless currency == other.currency
cents <=> other.cents
end
def negative?
cents < 0
end
def to_s
format = CURRENCY_FORMATS.fetch(currency, "%{amount} %{code}")
format(format, amount: cents / 100.0, code: currency)
end
def hash
[cents, currency].hash
end
def eql?(other)
other.is_a?(Money) && cents == other.cents && currency == other.currency
end
class CurrencyMismatch < StandardError; end
CURRENCY_FORMATS = {
"USD" => "$%{amount:.2f}",
"EUR" => "€%{amount:.2f}",
"JPY" => "¥%{amount:.0f}"
}.freeze
end Four design choices make this a real value object, not a "class shaped like one":
It is immutable. The constructor calls freeze on the instance. There is no cents= setter, no add! mutating method. Every operation that "changes" the money returns a new Money, the same way "abc".upcase returns a new string instead of modifying the original.
It implements ==, hash, and eql? by value. Two Money instances with the same cents and currency are equal. They have the same hash, so they work as hash keys. They satisfy eql?, so they work in Sets. Identity-by-value is what makes a value object usable like a primitive.
It includes Comparable with a context-aware <=>. Comparing USD to EUR returns nil — the value object refuses to silently produce an incorrect answer. The class encodes the rule "you cannot compare different currencies" so no caller can forget it.
It raises rather than returns garbage. Adding USD and JPY raises a CurrencyMismatch error. The alternative — silently treating the cents as comparable — would produce a "9 + 100 = 109" bug where 9 dollars somehow grew by adding 100 yen.
With Money in place, the Order model becomes much smaller:
class Order < ApplicationRecord
# columns: total_cents, currency
def total
Money.new(total_cents, currency)
end
end
# usage:
order.total.to_s # "$129.99"
order.total + other.total # raises if currencies mismatch
order.total > previous.total # nil if currencies mismatch
order.total.negative? # money rule, not order rule Rails core does this too
The most famous value object in Rails is one you use without thinking about it. When you write 5.minutes or 1.day.ago, you are calling methods that return ActiveSupport::Duration instances. The class is in activesupport/lib/active_support/duration.rb and it is a textbook value object:
- Immutable.
5.minutesnever changes; arithmetic on durations returns new durations. - Equality by value.
5.minutes == 5.minutesis true.5.minutes == 300.secondsis also true, because they represent the same duration. - Operations return new instances.
5.minutes + 1.dayis a new Duration combining both. - Context-encoded rules. You cannot meaningfully convert "5 months" to seconds without picking a starting date, because months have different lengths. Duration handles this correctly by carrying the parts (years, months, weeks, days) separately and only resolving them when you add the duration to a specific time.
If you have ever written 1.day.ago and not thought about it, you have used a value object correctly without noticing. That is the pattern's signature: when done right, it disappears into the way the code reads.
Why this design holds up
Four benefits, each one a fix for a specific Order-with-money-methods failure.
The rules live in one place. Currency formatting, comparison, addition, all in Money. Order, Invoice, Refund, Payout all use it without re-implementing anything. A bug fix in money handling lands in one file and applies across every model that has a price.
Bad combinations cannot compile. Adding USD to JPY raises. Comparing different currencies returns nil. The class makes it impossible for a caller to silently produce the wrong answer, which is the failure mode the Order-version had built right in.
The code reads at the right level of abstraction. order.total > previous.total is what you actually mean. order.total_cents > previous.total_cents && order.currency == previous.currency is the implementation detail you no longer have to think about. The signal-to-noise ratio of business code goes up.
Tests get small and obvious. Test Money.new(99, "USD") + Money.new(1, "USD") == Money.new(100, "USD"), and you have covered an axis of behavior that every model in the app relies on. One value object test guards every caller.
When you should reach for one
Three signals tell you a concept deserves to be a value object:
- The concept has its own behavior. Money has addition, comparison, formatting. Coordinates have distance and bearing. DateRange has overlap, contains?, union. If the methods you keep writing about a column are really methods about that column's underlying concept, the concept deserves a class.
- The concept appears on more than one model. Money lives on Order, Invoice, Refund, Subscription. Address lives on User, Shipment, Billing. The duplication is the signal.
- The concept has rules that the model is not enforcing. The Order model "supports" comparison across currencies, in the sense that it does not stop you. The Money value object refuses. The right abstraction makes invalid states unrepresentable; the model-based version makes them quietly possible.
Common value-object candidates worth looking for in your app: Money, Coordinates, Address, DateRange, PhoneNumber, EmailAddress, Url, Color, Percentage, FileSize, IpAddress, SocialSecurityNumber, RoleName, Permission. Every one of these is a concept with its own rules that tends to land as scattered methods on whatever model happens to store it.
The principle at play
The SRP rule says a class should have one reason to change. Value objects are SRP applied to the smallest, most overlooked unit: a concept that lives inside a model as one or two columns and a handful of scattered methods. The concept has its own rules of behavior, and those rules belong to the concept, not to whichever entity happens to carry the columns.
The deeper move, going back to Eric Evans, is that your code should mirror how domain experts talk. A finance team does not say "the order's total_cents." They say "the order total" and they think of it as money — something you can add to other money, compare to other money, and format for display. Value objects let the code use the same vocabulary as the people who own the domain, which makes the code easier to discuss, easier to debug, and easier to evolve.
The pattern also has a structural payoff: value objects move correctness from runtime to construction time. The day a caller tries to add 9 dollars to 100 yen, the Money version raises. The Order-with-cents version returns 109 and continues silently. The right abstraction does not only organize the code; it eliminates whole classes of bugs.
Practice exercise
- Open the largest model in your app. Find any group of columns that always appear together:
amount_cents+currency,street+city+zip+country,start_at+end_at. Each group is a value-object candidate. - For each group, search the model file for methods that operate on those columns: formatting, comparison, validation, arithmetic. The more methods, the stronger the case.
- Now grep the whole codebase for the same column names:
grep -rn "amount_cents" app/. If the columns show up on multiple models with similar surrounding code, the value object will pay for itself across all of them. - Sketch the value-object class. Constructor, equality, key behaviors. Imagine the calling code if the class existed. If the code reads much better, build it.
Closing the series
You have now seen seven extractions in service of the Single Responsibility Principle: services for verbs, forms for validating inputs, queries for filtered reads, policies for authorization, decorators for display, concerns for cross-cutting capabilities, and value objects for self-contained concepts. All seven share the same instinct: when a class has more than one reason to change, give the extra reasons their own home.
None of these patterns are mandatory. None of them earn their weight at small scale. A fresh Rails app with one resource and one form does not need any of them. The skill is knowing the moment a fat model or fat controller crosses the line from "fine" to "the next change will touch unrelated code." That moment is when a senior reaches for one of these tools.
Bryan Helmkamp wrote the post that gave most of these patterns their names in 2012, and the original title was "7 Patterns to Refactor Fat ActiveRecord Models." The seven in his list are the seven you have now read. Thirteen years later, they are still the seven moves Rails seniors reach for first. Not because the language has stood still, but because the underlying SRP shape is the right one.
The best calibration is to read the source of apps that have already crossed the threshold. Mastodon, Forem, Discourse, and Gumroad each have hundreds of these classes, organized in slightly different ways but converging on the same core moves. The more you read, the faster you will see the shape when it is time to extract one in your own code.