There are a bunch of tricky edge-cases to look out for when implementing a value object in Ruby. I often catch these when reviewing pull requests. It’s possible that someone sent a link here from just such a review! Here is a handy overview of expected value object semantics in Ruby.
What is a value object?
Value objects are richer domain objects that typically replace the use of a
primitive. For example, we might have a Duration
class that represents an
amount of time instead of representing this value as a raw integer (this is
separate from the core Time
class that represents a moment in time).
class Duration
def initialize(minutes)
@minutes = minutes
end
end
Equality
There are two high-level “concepts of equality” in Ruby:
- Equal by identity (objects being compared are the same object in memory)
- Equal by value (objects being compared represent the same value)
By default, primitives and structs compare by value while instances of user-created classes compare by identity. When we create a value object, we are trying to override the default to compare by value rather than by identity.
For example, a duration object representing 5 minutes should be considered equal to a different instance also representing 5 minutes but doesn’t by default because it is an instance of a user-created class.
Duration.new(5) == Duration.new(5)
# => false
Semantics
Two value objects that represent the same value should:
==
each other- NOT
equal?
each other (equal?
should compare by identity, not by value) hash
to the same value (so we don’t break hash keys)eql?
each other (Ruby expects objects that have the samehash
to beeql?
to each other)
class Duration
attr_reader :minutes
def initialize(minutes)
@minutes = minutes
end
def ==(other)
minutes == other.minutes
end
alias_method :eql?, :==
def hash
[self.class, minutes].hash
end
end
Hashes
#hash
and #eql?
are important to allow our value object to be used as a hash
key correctly. This allows us to index into hashes with any object that’s equal
to their key (our natural intuition) rather than only being allowed to use the
exact same instance.
events_by_duration =
{
Duration.new(5) => [event1, event3],
Duration.new(10) => [event2]
}
# This would return nil if hash and eql? where not overriden since
# we are techinically using a different instance than was used
# when defining the hash.
five_min_events = events_by_duration[Duration.new(5)]
Structs
Ruby structs implement these semantics by default! They can be an easy way to cheaply add value objects to your project, although I like to move to a real object once I start needing custom methods.
Duration = Struct.new(:minutes)
Duration.new(5) == Duration.new(5)
# => true
Bonus
While the equality rules above are the only ones that are strictly required, below are extra behaviors I commonly add to my value objects.
In general, value objects should also be immutable. Any methods that would modify the object (e.g. addition) should instead return a new instance. Bonus points for freezing the object in the constructor.
class Duration
def initialize(minutes)
@minutes = minutes
freeze
end
def +(other)
Duration.new(self.minutes + other.minutes)
end
end
Additionally, it may be nice to implement <=>
and include Comparable
. This
can get your ==
for free. It will also allow you to use your value in a
range. Add succ
if you want ranges of your object to be iterable.
Testing
Want to try and remember to implement these correctly on all value objects on your project? This may be an actually good use for an RSpec shared example. See this gist for what that might look like.
RSpec.describe Duration do
it_behaves_like "a value object"
end