Excerpt
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.
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
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
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.
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
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
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 same hash to be
eql? 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
#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)]
As of Ruby 3.2, you probably want to use the immutable Data
class for your value objects. Struct is still available if you
want something mutable.
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
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.
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
If you enjoyed this post, you might also like: