Kushagra Singh
When two or more primitive values consistently show up together in our code, it can be a sign an abstraction is missing from our ActiveRecord model.
We'll step through an example in an Inventory management application, where restaurant managers measure the quantity of an ingredient, adding and removing purchased amounts.
Let's start with what the migration and model look like.
# db/migrate/20260818000100_create_ingredients.rb
class CreateIngredients < ActiveRecord::Migration[8.1]
def change
create_table :ingredients do |t|
t.string :name
t.decimal :quantity_value
t.string :quantity_unit
t.timestamps
end
end
end
The simplified Ingredient treats units as strings, requires exact unit matches and avoids looking at conversions to keep the example light. The production implementation takes care of those concerns.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
validates :name, presence: true
def quantity
return "" if quantity_value.nil? || quantity_unit.nil?
"#{quantity_value} #{quantity_unit}"
end
def add_quantity(quantity_string)
value, unit = parse_quantity(quantity_string)
ensure_same_unit!(unit)
self.quantity_value = quantity_value + BigDecimal(value)
save!
end
def deduct_quantity(quantity_string)
value, unit = parse_quantity(quantity_string)
ensure_same_unit!(unit)
self.quantity_value = quantity_value - BigDecimal(value)
save!
end
private
# simplified for the example
def parse_quantity(quantity_string)
quantity_string.split(" ", 2)
end
def ensure_same_unit!(unit)
return if unit == quantity_unit
raise ArgumentError, "units are not the same: #{quantity_unit} and #{unit}"
end
end
A few observations we can make from the code:
- Almost every method in the
Ingredientclass deals with the:quantity_valueand the:quantity_unitattributes. - The public methods
:add_quantityand:deduct_quantityboth have "quantity" as the suffix and the private:parse_quantityas well.
The implementation of these methods is more concerned with the quantity than the ingredient. As someone reading a class, we'd expect it to communicate its behaviour and data rather than it being responsible for how quantity is added, deducted, and parsed.
To a new reader, who is trying to understand what Ingredient does and is responsible for, this is a distraction and is cognitively taxing.
Value Object Clues
Unfactored code usually highlights itself, in the above example there are two clues that a Value Object might be missing.
- Two or more attributes show up together consistently,
:quantity_valueand:quantity_unitin our example. This is known as a Data Clump. - Several methods have
quantityin their name, a repeating prefix or a suffix, can sometimes suggest that an abstraction might be missing.
As an exercise, try looking for these signs in one of your projects, it's quite satisfying to find candidates for your next refactor.
Copy-Paste Refactoring
We'll extract a new class called Measurement, and copy-paste in the methods from the Ingredient class, for now we'll keep the measurements mutable.
# app/models/measurement.rb
class Measurement
attr_accessor :value, :unit
def self.from(string)
value, unit = string.split(" ", 2)
new(value, unit)
end
def initialize(value, unit)
@value = value.nil? ? nil : BigDecimal(value.to_s)
@unit = unit
end
def add_quantity(quantity_string)
added_value, added_unit = parse_quantity(quantity_string)
ensure_same_unit!(added_unit)
self.value = value + BigDecimal(added_value)
self
end
def deduct_quantity(quantity_string)
deducted_value, deducted_unit = parse_quantity(quantity_string)
ensure_same_unit!(deducted_unit)
self.value = value - BigDecimal(deducted_value)
self
end
def to_s
return "" if value.nil?
"#{value} #{unit}"
end
# Lets the value object work with Rails' presence validations.
def blank?
value.blank? && unit.blank?
end
private
def parse_quantity(quantity_string)
quantity_string.split(" ", 2)
end
def ensure_same_unit!(other_unit)
return if unit == other_unit
raise ArgumentError, "units are not the same: #{unit} and #{other_unit}"
end
end
We've done a small refactoring here: renamed some variables, moved quantity related behaviour into Measurement , added an initializer and a parser for the new object.
Next, we'll modify the Ingredient class, to swap out the references to the old code with the Measurement object.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
# ...
def quantity
Measurement.new(quantity_value, quantity_unit).to_s
end
def add_quantity(quantity_string)
measurement = Measurement.new(quantity_value, quantity_unit)
measurement.add_quantity(quantity_string)
self.quantity_value = measurement.value
save!
end
def deduct_quantity(quantity_string)
measurement = Measurement.new(quantity_value, quantity_unit)
measurement.deduct_quantity(quantity_string)
self.quantity_value = measurement.value
save!
end
# ...
end
After making that change, our class still has issues that we had discussed earlier. We will trust the process and keep making small changes (while running our tests/specs).
Idiomatic Measurements
We can add two measurements using the :add_quantity method:
# rails console
> measurement = Measurement.from("10 kg").add_quantity("5 kg")
>
> puts measurement
=> 15 kg
But it would read much better, if we could add measurements like we add any two numbers in Ruby, using the + operator.
# rails console
> measurement = Measurement.from("10 kg") + Measurement.from("5 kg")
>
> puts measurement
=> 15 kg
We'll replace add_quantity and deduct_quantity methods with + and - in the Measurement class, so the API feels familiar and coherent.
We also make the operators return a new instance of the same class using self.class.new(value + other.value, unit) , and expose the attributes through readers rather than writers. This makes measurements immutable.
Value objects are identified by their values rather than by an identity such as an id. Making Measurement immutable lets us preserve that property: adding to a measurement produces a new measurement rather than changing the existing one.
# app/models/measurement.rb
class Measurement
attr_reader :value, :unit
# ...
def +(other)
ensure_same_unit!(other)
self.class.new(value + other.value, unit)
end
def -(other)
ensure_same_unit!(other)
self.class.new(value - other.value, unit)
end
# ...
private
def ensure_same_unit!(other)
return if unit == other.unit
raise ArgumentError, "units are not the same: #{unit} and #{other.unit}"
end
end
In the console, we now can.
# rails console
> measurement = Measurement.from("10 kg") + Measurement.from("5 kg")
=>
> puts measurement
=> 15 kg
>
> # + works well for two measurements. For a whole collection,
> # every purchase logged this month. :reduce is the better fit
>
> measurements = [Measurement.from("10 kg"), Measurement.from("5 kg"), Measurement.from("1 kg")]
> measurements.reduce(:+).to_s
=> "16 kg"
Next, let's see how the Ingredient class changes.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
# ...
def quantity
Measurement.new(quantity_value, quantity_unit)
end
def quantity=(measurement)
self.quantity_value = measurement.value
self.quantity_unit = measurement.unit
end
def add_quantity(quantity)
self.quantity += quantity
save!
end
def deduct_quantity(quantity)
self.quantity -= quantity
save!
end
# ...
end
Now we can use the newly added + and - methods added to measurement, and use that in Ingredient#add_quantity and Ingredient#deduct_quantity.
We added a setter and a getter for quantity as well. This makes our forms cleaner too, by replacing two form inputs one for quantity_unit and quantity_value each. We can present the user with a single quantity input.
# app/views/ingredients/_form.html.erb
<%= form_with model: ingredient do |form| %>
<%= form.text_field :quantity %>
<%= form.submit "Save" %>
<% end %>
Composing quantity with composed_of
We'll now use the composed_of macro in the Ingredient class and we can remove the setter and the getter.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
composed_of :quantity,
class_name: "Measurement",
mapping: { quantity_value: :value, quantity_unit: :unit },
converter: ->(string) { Measurement.from(string) }
# converter: :from
# Passing a symbol to converter also works,
# and is less verbose, used the lambda for the examples sake.
# ...
end
We can read that as Ingredient is composed_of quantity, to make sense of the relationship between the two objects.
Using composed_of allows us to pass Measurements in the where clause when querying for Ingredients.
# rails console
> Ingredient.where(quantity: Measurement.from("12 kg"))
> Ingredient Load (0.5ms) SELECT "ingredients".* FROM "ingredients"
WHERE "ingredients"."quantity_value" = 12.0
AND "ingredients"."quantity_unit" = 'kg'
LIMIT 11 /application='Playground'/
=> [#<Ingredient:0x0000000117962338 id: 1, created_at: "2026-08-18 21:09:27.019164000 +0000", quantity_unit: "kg", quantity_value: 0.12e2, updated_at: "2026-08-19 12:16:43.218267000 +0000">]
composed_of allows ActiveRecord to map Measurement.from("12 kg") to its constituent primitives, quantity_value and quantity_unit when generating SQL.
Let's look at the syntax we've used,
- The
class_nameis used to tell the macro what class to use to instantiate the object, if the class was calledQuantity, we could have omitted the option. - The
mappingoption is used to define which attributes of the value object will map with which columns/attributes of the entity. So, in this case,Ingredient#quantity_valuemaps toMeasurement#value. - The
converterruns when we runingredient.quantity = "5 kg", here is a simplified version of what happens in the background to make better sense of theconverteroption.
# We assign a String
ingredient.quantity = "5 kg"
# Rails sees that it isn't already a Measurement,
# so it invokes the converter
measurement = Measurement.from("5 kg")
#suppose this returns:
measurement.value # => 5
measurement.unit # => "kg"
# Rails uses the mapping to assign the columns
ingredient.quantity_value = measurement.value
ingredient.quantity_unit = measurement.unit
We can find more options that can be passed in to get the macro to work according to our specific use-case in the Rails documentation.
Quantify All the Things
Our Inventory management system is growing, restaurant managers would like to get notified when an Ingredient is running low, but each ingredient can have different quantities to signal that a reorder might be in order :).
To track each ingredient's threshold quantity, we decided to add another column, let's call it reorder_point_quantity, Adding the columns reorder_point_quantity_value and reorder_point_quantity_unit is straightforward with a migration.
Let's add the attribute to the Ingredient model
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
composed_of :quantity,
class_name: "Measurement",
mapping: { quantity_value: :value, quantity_unit: :unit },
converter: :from
composed_of :reorder_point_quantity,
class_name: "Measurement",
mapping: { reorder_point_quantity_value: :value, reorder_point_quantity_unit: :unit },
converter: :from
# ...
end
And that's it, reorder_point_quantity now gets the same Measurement behaviour as the quantity attribute, by essentially adding a single line. Imagine adding the new attribute when we were working with the first version of our Ingredient class.
Let's make it so that we can make it even easier to make a column quantifiable. We'll start with creating a Quantifiable concern.
# app/models/concerns/quantifiable.rb
module Quantifiable
extend ActiveSupport::Concern
module ClassMethods
def quantify attribute
value_attribute = :"#{attribute}_value"
unit_attribute = :"#{attribute}_unit"
composed_of attribute,
class_name: "Measurement",
mapping: { value_attribute => :value, unit_attribute => :unit },
converter: :from
end
end
end
The quantify method does most of the heavy lifting, constructs the value_attribute and unit_attribute , based on the value of attribute passed in, and passes it further to composed_of.
With that refactoring, our Ingredient class becomes:
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
include Quantifiable
quantify :quantity
quantify :reorder_point_quantity
# ...
end
There is a case against Quantifiable in its current form, the concern simply wraps the :composed_of method, which makes it harder to reason why it exists.
In the production version of the concern, it became home for some callbacks and a few validations that I was fed up of typing a few too many times. I ended up using the quantify macro in a lot of other places. Once we are able to spot and extract a value object, we often end up using it in other models as well. Waste, Consumption, Batch and DailyStock to name a few instances.
Comparing Quantities
To notify managers, when an ingredient's quantity is below the specified threshold, we'll define a callback to keep it simple.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
include Quantifiable
quantify :quantity
quantify :reorder_point_quantity
after_commit :notify_managers_of_understock, if: :understock?
def understock?
quantity < reorder_point_quantity
end
# ...
private
# In a real application, we might want to detect when an ingredient
# becomes understocked rather than is understocked, so that repeated
# saves don't produce repeated notifications
def notify_managers_of_understock
Notification::Understock.notify(recipients: managers)
end
end
The understock? method will not work, and will throw an error along the lines of:undefined method '<' for an instance of Measurement (NoMethodError)
We'll fix that by adding the ability to compare measurements with other measurements, we can add that using the Comparable module. And defining the Spaceship operator <=>(other) on it.
# app/models/measurement.rb
class Measurement
include Comparable
attr_reader :value, :unit
def <=>(other)
ensure_same_unit!(other)
value <=> other.value
end
# ...
end
<=> returns -1, 0, or 1 depending on whether the measurement is smaller than, equal to, or greater than the other measurement's value. Comparable uses that one method to provide the rest of the comparison operators.
By adding the mixin and the three-way comparison, We can start comparing measurements, this now works:
# rails console
> Measurement.from("20 kg") > Measurement.from("10 kg")
=> true
>
> Measurement.from("20 kg") > Measurement.from("30 kg")
=> false
>
> Measurement.from("20 kg") == Measurement.from("20 kg")
=> true
The Ingredient#understock? error that we encountered earlier is not going to be a bother anymore.
Another benefit of defining how to compare measurements is that we can now sort them too.
# rails console
> [Measurement.from("20 kg"), Measurement.from("10 kg"), Measurement.from("2 kg")].sort.map(&:to_s)
=> ["2 kg", "10 kg", "20 kg"]
On the testing side, with this change we would have been able to extract the quantity/measurement related specs in to measurement_spec.rb , making ingredient_spec.rb lighter.
Wrapping up
We've come a long way with our refactoring, now that we have a dedicated Measurement object.
In hindsight, the initial implementation somewhat ran against the spirit of Tell, Don't ask. Rather than giving a quantity object the behaviour needed to manipulate itself, Ingredient was reaching into the quantity's constituent data.
The Ingredient object was exhibiting the Feature Envy smell, it was accessing quantity data more than its own data.
We also have a natural place, the Measurement object, to implement future responsibilities like conversion or scaling, or working with not only Weight, but Volume and Length as well.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
include Quantifiable
quantify :quantity
quantify :reorder_point_quantity
# We can add validations to the quantifiable fields
validates :name, :quantity, presence: true
after_commit :notify_managers_of_understock, if: :understock?
# ...
def add_quantity(quantity)
self.quantity += quantity
save!
end
def deduct_quantity(quantity)
self.quantity -= quantity
save!
end
def understock?
quantity < reorder_point_quantity
end
private
def notify_managers_of_understock
Notification::Understock.notify(recipients: managers)
end
end
Of course, two attributes appearing together isn't enough reason to always extract a value object. The abstraction should have behaviour of its own, and introducing it should be justified by that behaviour or by its reuse. In this example, quantities already had parsing, arithmetic, comparison, formatting, making a stronger case for Measurement's existence.
The factored version is less cognitively taxing, we can see at a glance which attributes can be measured at a high level, if we want to reach for the details and explore the inner workings, we can zoom in and read at a lower level of abstraction and look at the Measurement Value object.
P.S. While writing the code examples, I've omitted a fair deal to make the post not too dense. For conversions and other unit related operations I ended up using the Shopify/measured gem, which does a great job of encapsulating various measurement and their unit related gotchas, the Measurement still exists in production and handles a great deal.
Next