Value Object Semantics in Ruby

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:

  1. Equal by identity (objects being compared are the same object in memory)
  2. 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:

  1. == each other
  2. NOT equal? each other (equal? should compare by identity, not by value)
  3. hash to the same value (so we don’t break hash keys)
  4. 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

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