Purposes & Properties of Value Objects
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.
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.
4.3 out of 5 stars.
Based on Trustpilot.com and G2.com reviews.