Value Objects in Rails
While writing yesterdays post about the usage of Hash
vs class
, I couldn’t
help but think of another powerful tool that helps us to build a well-defined
model of our problem domain in the programming language of our choice: the
value object.
To understand the value of value objects (scnr), let’s take an example:
42
. “What is 42
?”, you might be tempted to ask - and rightfully so.
In Ruby it might be an instance of Integer
, other languages may have cause
to use a more restrictive definition like u8
or i16
. But still - that
doesn’t help us to understand what 42
might mean.
It could be the result of having calculated 21*2
, but it might also be a
distance (in whatever unit), a price (in whatever currency), a weight and so
on.
This is where value objects come in: they help us to resolve these ambiguities.
Hands-on activity
To give a concrete example, we’re going to implement a model for a basic fitness tracking app. There will be activities like walking, cycling, swimming and so on - we don’t care about that right now - and users can track how far they have gone during any given activity.
class Activity < ApplicationRecord
belongs_to :user
attribute :distance, :integer
end
First approach:
Given the model of the initial iteration above, the problem becomes clear: we can’t be sure what unit the distance
attribute is measured in. It could be meters, kilometers, yards or multiples of the Planck length 🤪.
As a first version, you could rename the attribute to distance_meters
. Now if you’re asked to show the total number of miles traveled, you’d have to do something like current_user.activities.sum(:distance_meters) / 1609.344
. Sure, it works… but having to append _meters
all of the time and having to know and apply the conversion factor manually is a bit ugly.
Going the Distance
Instead, we’re going to implement a value object to represent distances.
class Distance
attr_reader :meters
class << self
def from_meters(meters)
new.tap { |d| d.instance_eval { @meters = meters } }
end
end
# see the link above for the full code on Github
def miles
meters * 6.213712e-4
end
def parsecs
meters * 3.240779e-17
end
end
Given that this is Ruby, you could even monkey-patch Numeric
:
class Numeric
def meters
Distance.from_meters(self)
end
end
This will allow you to write:
flown = 400000000000000000.meters
puts "You flew the Kessel Run in #{flown.parsecs} parsecs. Impressive, but not a record!"
which will yield:
You flew the Kessel Run in 12.963116000000001 parsecs. Impressive, but not a record!
Constructors from other units are left as an exercise to the reader - for now, though, we’ll skip implementing them to more easily see type casting at work.
Integrating into the Attributes API
To properly benefit from this construct, we’re going to have to register it with the ActiveRecord Attributes API.
class DistanceInMetersType < ActiveRecord::Type::Value
def cast(value)
return value if value.is_a?(Distance)
raise ArgumentError, "Can't cast from #{value.class}" unless value.respond_to?(:to_f)
Distance.from_meters(value.to_f)
end
def serialize(value)
raise ArgumentError unless value.is_a?(Distance)
value.meters
end
end
# Note that ActiveModel types have to be registered separately and have a slightly different signature here
ActiveRecord::Type.register(:distance_in_meters, DistanceInMetersType)
and now you can use it to output the total traveled distance in miles very comfortably:
class Activity < ApplicationRecord
# *snip*
attribute :distance, :distance_in_meters
end
# And use it:
> puts "You traveled #{current_user.activities.sum(:distance).miles} miles in total! 🥳"
Now the beauty here is that even though Activity.sum(:distance)
only creates a stand-alone value without a surrounding instance of Activity
, the model still defines it as distance_in_meters
- and ActiveRecord is smart enough to instantiate an object of the Distance
class for us upon which we can invoke .miles
.
Non-Ruby excourse: typed languages
In typed languages, there is a precursor to full-blown value objects: type aliases. Using a type alias can help you clarify the meaning of your function arguments.
For example, given the example below
package main
import "fmt"
func circumnavigations(distance_walked float32) (float32) {
return distance_walked / 24859.734
}
func main() {
walked := circumnavigations(1234)
fmt.Println("I walked the earth", walked, "times")
}
you can facilitate understanding a lot by replacing the magic number with a properly-named constant and introducing a type alias.
package main
import "fmt"
type miles = float32
const EARTH_CIRCUMFERENCE_IN_MILES = 24859.734
func circumnavigations(distance_walked miles) (float32) {
return distance_walked / EARTH_CIRCUMFERENCE_IN_MILES
}
func main() {
walked := circumnavigations(1234)
fmt.Println("I walked the earth", walked, "times")
}
A word on proportionality
Of course, these constructs would be somewhat overblown if you just want to log your own fitness activities in an app that might reach a couple of hundreds lines of code in total at best. 😅
On the other hand, if you’re planning on writing a scalable codebase which is going to manage many different activities for thousands of users across the world, you’re crossing from programming territory into software engineering - and will be glad for having built a proper model very soon.