In Progress
Unit 1, Lesson 21
In Progress

Primitive Obsession

Video transcript & code

Ruby is one of the most comprehensively object-oriented programming languages in common use today. After all, in Ruby, everything is an object, including values that are commonly thought of as "primitives", like numbers, strings, and arrays. And yet, for all that Ruby is aggressively OO in design, it's still surprisingly easy to infect our code with modes of thinking that have more to do with procedures acting on "dumb" data.

For instance, let's say we're working on an application that manages courses at a school. We have a repository full of courses.

Let's pull one out. We can see it's a Course object, one of our domain models.

One of the core attributes of a course is how long it lasts. Let's ask this course .

require "./courses"

COURSES[:potions].class         # => Course
COURSES[:potions].duration      # => 3

Huh. 3. Three what? Weeks? Months?

Without consulting the class documentation—assuming there is any—we have no way of knowing. That's not very self-documenting.

Maybe it would be better if we added a clarifying accessor method which included the unit of measurement in its name.

require "./courses2"

COURSES[:potions].duration_in_months
# => 3

That's more revealing. But it's a bit unwieldy. Are we going to be consistent and name all of our methods this way?

And in some ways, it's still mysterious. It returned an integer this time. Will it always return an integer? What about a six week course? Will it be fractional then?

It also kind of suggests that if duration_in_months exists, then other methods like #duration_in_days exist. Which could be a source of surprise and annoyance.

require "./courses2"

COURSES[:potions].duration_in_weeks
# =>

# ~> NoMethodError
# ~> undefined method `duration_in_weeks' for #<Course:0x005631557fd230 @title="Potions 101", @duration=3>
# ~>
# ~> xmptmp-in1320ucI.rb:3:in `<main>'

Now what if we start running some shorter courses, less than a month long? At this point we realize that we need Course objects to support finer granularity than whole months.

Now we can have a course that's three weeks long.

How many months long is this course? Good question. Looks like it's zero months long.

Is this the right answer? Would a fraction or decimal be better? We don't know. It kind of depends on how this method will be used. Even if a fractional number was a better choice for this value, would making that change break other users of the class? Does it even make sense to ask how many months a three-week course takes? Again, we're not sure.

And we still have the base #duration attribute hanging around. Which unit does it correspond to? Looks like it corresponds to weeks now.

I hope there's no code left around that assumes duration returns a number of months.

require "./courses3"

COURSES[:scrying].duration_in_weeks
# => 3
COURSES[:scrying].duration_in_months
# => 0
COURSES[:scrying].duration
# => 3

Time goes on, and we've introduced new micro-courses, which are just a few days in length.

Now we have a course which is 3 days long.

Which apparently counts as zero weeks long, and zero months long.

require "./courses4"

COURSES[:hexes].duration_in_days
# => 3
COURSES[:hexes].duration_in_weeks
# => 0
COURSES[:hexes].duration_in_months
# => 0

This is a lot of methods for effectively a single attribute. Are we going to duplicate this method set for every duration value in our application?

If we do, our object APIs will explode in size.

If we don't, they won't be consistent with each other.

Another thing about these methods: they kind of suggest that there would be matching setter methods. But there aren't.

require "./courses4"

COURSES[:hexes].duration_in_months = 2


# ~> NoMethodError
# ~> undefined method `duration_in_months=' for #<Course:0x00562f1d39f2b8 @title="Hexes", @duration=3>
# ~>
# ~> xmptmp-in1320gUb.rb:3:in `<main>'

If we want to modify the duration of the potions course to two months instead of three, we still have to use the raw #duration setter.

Now, what was the base unit of measure again?

Well, I guess it wasn't months. Was it weeks? Days?

require "./courses4"

COURSES[:potions].duration = 2 # ???
COURSES[:potions].duration_in_months
# => 0

But let's move on from our issues with modifying objects. Presumably, we're going to have to display course information in various places in our application.

Here's a simple rendering helper, of a sort you may be familiar with from Rails projects. Given a course object, its job is to render course information.

There are a couple of assumptions we've hardcoded into this helper. First, the assumption that a Course will return a number of days from duration. We'd better hope that never changes!

And second, we've embedded the unit "days" in the text.

Let's pass a couple of our courses in here. Here's the tiny "hexes" course.

And here's the potions course.

Is number of days really the best unit to display for a three-month course?

require "./courses4"

def render_course_info(course)
  "#{course.title} (#{course.duration} days)"
end

render_course_info(COURSES[:hexes])
# => "Hexes (3 days)"
render_course_info(COURSES[:potions])
# => "Potions 101 (90 days)"

Let's say we decide that we'd really rather display courses differently depending on their length. A typical way to handle this might be to add a second helper method.

This method also hardcodes a dependency on Course returning #duration in terms of days, as well as on the various duration in days/weeks/months methods. In addition, it embeds some fairly arbitrary semantic assumptions about what courses were planned in terms of days, weeks, or months. It's easy to imagine this logic falling out of date, and/or out of sync with other, similar logic.

We reference this new helper from the original helper.

Now the course display is customized for the lengths of courses.

require "./courses4"

def render_course_duration(course)
  if course.duration < 14
    "#{course.duration_in_days} days"
  elsif course.duration_in_days < 60
    "#{course.duration_in_weeks} weeks"
  else
    "#{course.duration_in_months} months"
  end
end

def render_course_info(course)
  "#{course.title} (#{render_course_duration(course)})"
end

render_course_info(COURSES[:hexes])
# => "Hexes (3 days)"
render_course_info(COURSES[:potions])
# => "Potions 101 (3 months)"

Let's take a step back for a moment. What are we doing here?

All we're trying to do is represent and display school courses with varying durations. This is basic, entry-level stuff! And yet, we've run into so many questionable choices along the way. So many design decisions where nothing we did felt quite right.

Why is this happening? Where did it all go wrong???

Our persistent problems stem from one source: the fact that we chose, probably without even really thinking about it, to represent the domain concept of a course duration as a simple integer value. This design choice is the code smell known as "primitive obsession". WikiWiki defines "primitive obsession" as: "using primitive data types to represent domain ideas".

Yes, in Ruby technically all values are objects. Even so, the computer science concept of a Fixnum, and the course management concept of "a duration of six weeks", are vastly different ideas.

How are we going to fix this? Well, I hate to leave you hanging, but this episode is running overtime already. In the next episode, we're going to use a lesser-known design pattern to address all of these problems, and come to a better understanding of our problem space in the process.

Until then, happy hacking!

Responses