Engineering

Purposes & Properties of Value Objects

Luca Guidi's profile picture Luca Guidi on

Last time we talked about how to reduce the abuse of primitive types, by the extraction of objects to represent concepts in our model domain.

But how shall we design these objects? Let's talk about their fundamental properties.

Imagine we're building a weather app. We recognized that floats weren't the right fit to express temperatures, because it was odd to ask if "a float is cold". Better to extract Temperature. We call it a value object.

class Temperature
  def initialize(temperature)
    @temperature = Float(temperature)
  end

  protected

  attr_reader :temperature
end

We have defined a protected accessor (temperature), to let other instances of Temperature to have access to it.

Now let this object to answer that question.

Questions

The main purpose of a value object is answer meaningful questions for our application. Something like: "Is 37°F cold?".

class Temperature
  def initialize(temperature)
    @temperature = Float(temperature)
  end

  def cold?
    temperature < 59.0 # Fahrenheit degrees
  end

  protected

  attr_reader :temperature
end

Temperature.new(37).cold? # => true

Yay! We're now able to tell if a temperature is cold or not.

Comparability

If we look at the implementation of #cold?, we spot the same problem again: we're using a float to represent a model domain concept: the cold threshold temperature. That's primitive obsession biting again! We can use Temperature instead.

class Temperature
  def initialize(temperature)
    @temperature = Float(temperature)
  end

  def cold?
    temperature < COLD_THRESHOLD
  end

  protected

  COLD_THRESHOLD = new(59)
  attr_reader :temperature
end

Temperature.new(37).cold?
  # => temperature.rb:8:in `<': comparison of Float with Temperature failed (ArgumentError)

Ouch! We can't compare a Float (@temperature) with a Temperature instance (COLD_THRESHOLD).

What we want here is to make our Temperature to be comparable with other Temperature instances. Luckily there is a useful mixin in Ruby standard library: Comparable.

class Temperature
  include Comparable

  def initialize(temperature)
    @temperature = Float(temperature)
  end

  def cold?
    self < COLD_THRESHOLD
  end
  
  def <=>(other)
    temperature <=> other.temperature
  end
  
  def to_s
    temperature.to_s
  end

  protected

  COLD_THRESHOLD = new(59)
  attr_reader :temperature
end

Temperature.new(37).cold?                  # => true
Temperature.new(37) > Temperature.new(68)  # => false
Temperature.new(37) == Temperature.new(37) # => true

Comparable requires to define the spaceship operator (aka #<=>) and to return -1, 0 or 1 if other is respectively lesser, equal, or greater than. Now, thanks to Comparable, our Temperature is able to respond to comparability checks like lesser than (#<), or equal to (#==).

We're also capable to check if two temperatures are equal if they carry the same value.

Equality

Now we need to get a list of all the unique temperatures of the last month. We start from raw data which include duplicates:

[Temperature.new(44), Temperature.new(68), Temperature.new(68)].uniq
  # => [44.0, 68.0, 68.0] # WRONG

What's going on here? We expected to see 68.0 only once, but there is twice! This bug is caused by how a language like Ruby checks for unique entries.

It transforms the object identity (#object_id) into an integer which will remain constant during the life span of the process. This technique is called hashing.

We can tell our language how to check for identity equality, by providing our own hashing implementation.

class Temperature
  # ...
  alias eql? ==

  def hash
    temperature.hash
  end
end

But wait! We use a value (@temperature) to define its identity?

That's right! Because value objects define their identity by their value. 68°F is.. 68°F – and it will be always equal to 68°F.

[Temperature.new(44), Temperature.new(68), Temperature.new(68)].uniq
  # => [44.0, 68.0]

Immutability

Because we delegate important behaviors to the encapsulated value, we want it to not change. When we implement a hashing strategy we want it to be constant until the end of the program execution. If the value changes, we can't meet this requirement. We should freeze it.

In our case, the value that we hold is a Float, which is already frozen. But if we have other types like strings, hashes, arrays, we should explicitly do that. Preferably, we should define the getter as protected to prevent accidental data modifications.

Conclusion

We designed Temperature to be responsible for specific model domain questions, but also to behave like a primitive.

If you think, our language type system is made of basic "generic primitives", on top of which we build another layer of model domain "specific primitives": the value objects.

Share on Twitter and Facebook

Luca Guidi's profile picture

Luca Guidi

Former astronaut, soccer player, superhero. All at the age of 10. For some reason now I write code.

We think domain management should be easy.
That's why we continue building DNSimple.

Try us free for 30 days
4.5 stars

4.3 out of 5 stars.

Based on Trustpilot.com and G2.com reviews.