Fighting the primitive obsession with Value objects
… and check why 5600+ Rails engineers read also this
My previous post on read models intended to address something different, but I decided to focus on read model part and leave the other topic for a different one. There’s one thing which I dislike in the implementation. Using primitives to calculate the scores.
Projection
def calculate_scores(test_id, participant_id)
RailsEventStore::Projection
.from_stream(stream_name(test_id, participant_id))
.init(-> { Hash.new { |scores, skill_id| scores[skill_id] = { score: 0, number_of_scores: 0 } })
.when(
SurveyExecution::AnswerRegistered,
->(state, event) do
skill_id = event.data.fetch(:skill_id)
state[skill_id][:score] += event.data.fetch(:score)
state[skill_id][:number_of_scores] += 1
end
)
.run(Rails.configuration.event_store)
.reduce({}) do |scores, (skill_id, values)|
scores[skill_id] = values[:score] / values[:n]
scores
end
end
It accumulates the score in scope of a given skill so we can count the average and so on. This example is simplified, as you may suspect, the original is more complex.
We can do better
How can it be done differently? By introducing Value object. Before diving into the code, we should establish the correct definition of it. I like characteristics of Value object which Eric Evans put in his „Domain-Driven Design: Tackling the Complexity in the Heart of Software” book:
- It measures, quantifies, or describes a thing in the domain.
- It can be maintained as immutable.
- It models a conceptual whole by composing related attributes as an integral unit.
- It is completely replaceable when the measurement or description changes.
- It can be compared with others using Value equality.
- It supplies its collaborators with Side-Effect-Free Behavior
Probably the most common example of Value object you’ll meet is the Price
or MonetaryValue
which represents the
combo of BigDecimal
and a String
representing the currency. I’ll do something different then.
class AnswerScore
def initialize(skill_id, score)
@skill_id = skill_id
@score = BigDecimal(score.to_s)
end
attr_reader :skill_id, :score
def eql?(other)
other.instance_of?(AnswerScore) && skill_id.eql?(other.skill_id) && score.eql?(other.score)
end
alias == eql?
def hash
AnswerScore.hash ^ [skill_id, score].hash
end
end
What we got here, we are able to compare two different AnswerScore
by their values thanks to ==
, eql?
and hash
methods on our own:
irb(main):069:0> AnswerScore.new(123, 0) == AnswerScore.new(123, 0)
=> true
irb(main):070:0> AnswerScore.new(123, 0) == AnswerScore.new(123, 1)
=> false
irb(main):071:0> AnswerScore.new(123, 0) == BigDecimal("0")
=> false
irb(main):072:0> AnswerScore.new(123, 0) == AnswerScore.new(456, 0)
=> false
Same results will give us the .eql?
operator since ==
is alias of it.
Adding two value objects
Ok, you can compare two objects, what now? And there’s also an id, shouldn’t this be an Entity
? Nope, it shouldn’t,
we treat this id to distinguish scores of different skills. Adding two scores of two different skills wouldn’t make much
sense, right? Imagine adding money in dollars and pounds sterling without distinguishing the currency.
Let’s implement +
operator on the object then.
class AnswerScore
def initialize(skill_id, score)
@skill_id = skill_id
@score = BigDecimal(score.to_s)
end
attr_reader :skill_id, :score
def +(other)
raise ArgumentError unless self.class === other
raise ArgumentError if self.skill_id != other.skill_id
score + other.score
end
def eql?(other)
other.instance_of?(AnswerScore) && skill_id.eql?(other.skill_id) && score.eql?(other.score)
end
alias == eql?
def hash
AnswerScore.hash ^ [skill_id, score].hash
end
end
And there it is, we won’t be able to add anything wrong to our score:
# Same skills, different scores
irb(main):123:0> AnswerScore.new(123, 0) + AnswerScore.new(123, 1)
=> 0.1e1
# Different object
irb(main):124:0> AnswerScore.new(123, 0) + 5
Traceback (most recent call last):
5: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `<main>'
4: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `load'
3: from /Users/fidel/.rbenv/versions/2.7.3/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
2: from (irb):124
1: from (irb):107:in `+'
ArgumentError (ArgumentError)
# Scores of different skills
irb(main):126:0> AnswerScore.new(123, 0) + AnswerScore.new(456, 1)
Traceback (most recent call last):
5: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `<main>'
4: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `load'
3: from /Users/fidel/.rbenv/versions/2.7.3/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
2: from (irb):124
1: from (irb):107:in `+'
ArgumentError (ArgumentError)
Works great, but returns BigDecimal
and we want to add more AnswerScore
object to each other to cleanup and simplify
our projection:
def calculate_scores(test_id, participant_id)
RailsEventStore::Projection
.from_stream(stream_name(test_id, participant_id))
.init(-> { NullScore.new( })
.when(
SurveyExecution::AnswerRegistered,
->(state, event) do
state += AnswerScore.new(
skill_id: event.data.fetch(:skill_id),
score: event.data.fetch(:score)
)
end
)
.run(Rails.configuration.event_store)
.reduce(&:+)
.average_score
end
This won’t work, we don’t have a NullScore
, we should implement it:
class NullScore
def +(other)
raise ArgumentError unless AnswerScore === other
other
end
def eql?(other)
other.instance_of?(NullScore)
end
alias == eql?
def hash
NullScore.hash
end
end
It just returns first real Value object, after addition. Great starting point for our projection than hacking
internals of AnswerScore
to provide that behaviour.
Be immutable
Getting back to the AnswerScore
. We need to return a Value object from our AnswerScore
rather than raw
BigDecimal
value. Adding two scores is no longer a score, we should return ScoreSum
, probably.
class AnswerScore
def initialize(skill_id, score)
@skill_id = skill_id
@score = BigDecimal(score.to_s)
end
attr_reader :skill_id, :score
def +(other)
raise ArgumentError unless self.class === other
raise ArgumentError if self.skill_id != other.skill_id
ScoreSum.new(skill_id: skill_id, sum: score + other.score, n: 2)
end
def average_score
score.round(2)
end
def eql?(other)
other.instance_of?(AnswerScore) && skill_id.eql?(other.skill_id) && score.eql?(other.score)
end
alias == eql?
def hash
AnswerScore.hash ^ [skill_id, score].hash
end
end
class ScoreSum
def initialize(skill_id:, sum:, n:)
@skill_id = skill_id
@sum = BigDecimal(sum.to_s)
@n = Integer(n)
end
attr_reader :skill_id, :sum, :n
def +(other)
raise ArgumentError unless AnswerScore === other
raise ArgumentError if self.skill_id != other.skill_id
ScoreSum.new(sum: sum + other.score, skill_id: skill_id, n: n + 1)
end
def average_score
(score / n).round(2)
end
def eql?(other)
other.instance_of?(ScoreSum) && skill_id.eql?(other.skill_id) && sum.eql?(other.sum) && n.eql?(other.n)
end
alias == eql?
def hash
ScoreSum.hash ^ [skill_id, sum, n].hash
end
end
How it rolls:
irb(main):254:0> AnswerScore.new(123, 0) + AnswerScore.new(123, 1)
=> #<ScoreSum:0x00000001137b3770 @skill_id=123, @sum=0.1e1, @n=2>
irb(main):255:0> AnswerScore.new(123, 0) + AnswerScore.new(123, 1) + AnswerScor
e.new(123, 1)
=> #<ScoreSum:0x0000000112030a30 @skill_id=123, @sum=0.2e1, @n=3>
irb(main):256:0> [AnswerScore.new(123, 0), AnswerScore.new(123, 1), AnswerScore
.new(123, 1)].reduce(&:+)
=> #<ScoreSum:0x00000001137a8938 @skill_id=123, @sum=0.2e1, @n=3>
What this gives us:
- the objects are immutable, every time we do some operation, the new object is returned
- we clearly explain our concept
- we can incorporate specific behaviour for
AnswerScore
andScoreSum
, eg.average_score
method which for score is simply a score, but forScoreSum
it’s a sum divided by number of elements
Bad news
Our projection won’t work now. Because current implementation in Rails Event Store
framework doesn’t allow that. Initial implementation worked because we used the Hash
to maintain our state and we were
mutating on and on the same instance
of it😱
But there is light
WeDontDoThatHere = Class.new(StandardError)
def calculate_scores(test_id, participant_id)
Rails
.configuration
.event_store
.read
.stream(stream_name(test_id, participant_id))
.map do |event|
case event.event_type
when "SurveyExecution::AnswerRegistered"
AnswerScore.new(skill_id: event.data.fetch(:skill_id), score: event.data.fetch(:score))
else
raise WeDontDoThatHere
end
end
.reduce(&:+)
.average_score
end
Does the same, and even looks less magical, at least to me. And the NullScore
is obsolete now, we do map
—reduce
and there it is.