Primitive Obsession & Exceptional Values
Article Table of Contents
I’ve been working through Avdi Grimes’ Mastering the Object Oriented Mindset course.
One of the topics was using “whole values”, instead of being “primative obsessed”. The example Avdi gave was clear as day.
He used a course with a duration
attribute to show the problem.
course.duration
=> 3
3 what? weeks? days? months?
Of course, you could write a method like:
course.duration_in_weeks
=> 3
But now you’ll have trouble rendering this all over the place. You’d have conditionals every time you wanted to render courses in weeks (if it makes sense), or in months (if appropriate), or of course, days.
So, the solution is to use “Whole values”. This means an attribute should be a complete unit, in and of itself, and should need no further refining to be usable.
So, you should be able to do something like this:
course.duration
=> Months[3]
shorter_course.duration
=> Weeks[5]
So, here’s the basics of this Duration
class, that your units (like Days
, Weeks
, and Months
) inherit from:
class Duration
attr_reader :magnitude
def initialize(magnitude)
@magnitude = magnitude
freeze
end
def inspect
"#{self.class}[#{magnitude}]"
end
def to_s
"#{magnitude} #{self.class.name.downcase}"
end
alias_method :to_i, :magnitude
end
class Days < Duration; end
class Weeks < Duration; end
class Months < Duration; end
And it delivers pretty cool stuff:
main:0> Days.new(3)
=> Days[3]
main:0> Days.new(3).to_s
=> "3 days"
main:0> length = Weeks.new(3)
=> Weeks[3]
But having the option to call course.duration
and get Weeks[3]
as a response is… amazing. Or course.length.to_s
and get 3 weeks
. Super cool.
Avdi walked through the example code, but I was partial to having it available for playing around myself. So, I built a very simple test file.
Check out the full test suite, if you’re interested
The above gist also has the code that makes it all pass. I’m going to highlight just a few of the tests below:
def test_duration_is_months_object
assert_instance_of Months, @math.duration
end
This test (and a few others) make it explicit that when you call @math.duration
you don’t expect a primitive back - you expect an instance of the Months
class. Super cool.
def test_duration_inspect
assert_equal "Months[4]", @math.duration.inspect
end
We can “convert” our Duration value into a primitive (a string) by calling #inspect
on it. Other than this, though, the duration value lives as its own object.
The tests test some helper methods that Avidi mentioned, to make it a bit easier to render the course.duration
in a view:
def render_course_info(course)
"#{course.name} (#{render_value(course.duration)})"
end
def render_value(value)
case value
when Months
"#{value.to_i} gruling months"
when Weeks
"#{value.to_i} delightful weeks"
when Days
"a paultry #{value.to_i} days"
end
end
Exceptional Values #
So, we’ve got this method that takes input as a string, like “12 months”, and tries to convert it to Months[12]
.
If you are accepting data from a user, you’ll need to plan on invalid input, like “99 blinks”.
Here’s the first take of the conversion method:
def Duration(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
else
nil
end
end
This kind of works, but nil
isn’t a great place-holder. Now your view logic needs to do all sorts of special work to handle if there are nil
values, which of course there will be all the time, because if you call:
course = Course.new
course.name = "math"
course.duration = "12 days"
course.save
The course will have nil values auto-assigned simply because the user has not filled it in yet.
Anyway, so, as you might expect from someone talking about “whole values”, there’s a “whole value” implementation of an exception:
def Duration(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
else
ExceptionalValue.new(raw_value, reason: "unrecognized format")
# we create a new Exceptional Value object if we get unrecognized input
end
end
Here’s what that object might look like, using this exceptional value:
math = Course.new("Math")
math.duration = "a blink of an eye"
=> <struct Course
name="Math",
duration=<ExceptionalValue:0x00007fca79021188 @raw_value="a blink of an eye", @reason="unrecognized format">>
pretty cool, huh?