In Progress
Unit 1, Lesson 21
In Progress

Uniform Abstraction Level

Video transcript & code

In recent episodes we've been building a tiny app for listing school courses. The central domain model is a Course class.

c = Course.new

Let's count the kinds values we work with in the context of a course.

When we first create a Course, its name and duration attributes have nil values.

So that's one.

c.name                          # => nil
c.duration                      # => nil

If we assign a name to the course, it now has a String value, so that's two.

c.name = "Woolgathering 101"
c.name                          # => "Woolgathering 101"

If we set the course duration, the resulting attribute value is a Weeks object, which is one of the Whole Value duration classes we introduced in episode #401.

That's three types of value so far.

We saw in that episode how introducing Whole Value objects eliminated a whole swathe of tricky object design questions.

c.duration = "2 weeks"
c.duration                      # => Weeks[2]

Of course, users sometimes make mistakes in data entry, and not all values assigned to this attribute will be recognizable as a known type of course duration.

For this circumstance, we have the ExceptionalValue class, which is able to flag an attribute as being problematic, while still echoing back exactly what the user typed.

We introduced this class in episode #431. It and the Whole Value pattern both come from the CHECKS pattern language by Ward Cunningham, and they complement each other nicely.

So that's a fourth kind of attribute value.

c.duration = "1 fortnight"
c.duration
# => #<ExceptionalValue:0x00560ac26ad150
#     @raw="1 fortnight",
#     @reason="Unrecognized format">

In our views and controller actions, we make use of some special properties of these values.

For instance, in the form view, we can flag the duration field as incorrectly filled-out.

And in the form post handler, we check to see if any fields are exceptional and if so, ask the user to try again by re-rendering the form.

We're able to check for these problems, even though we have yet to introduce any kind of validations system to our model, because our Whole Value and Exceptional Value objects give us the complete semantic picture for each field. We're working at the domain level with these values, rather than at a primitive level.

Except… wait a minute. We just said that some of the possible field values are basic Strings and nil values. So how is this even working? The exceptional? predicate isn't a standard method on Ruby objects… so how does this even work?

The reason this isn't blowing up is that we kludged it to work. In order to make both String and nil objects behave alongside our whole values and exceptional values, we monkey-patched the Object class to respond to exceptional?.

This isn't really an ideal solution. You might recall that I talked about some of the less-obvious dangers of monkey-patching back in episode #226.

It's true that we could change to using refinements. But that turns out to have some surprising gotchas in this particular scenario, which I'm not going to go into today. And in any case it would just be a band-aid for the real problem.

What is the real problem? It's that the Course class deals in inconsistent levels of abstraction.

c = Course.new
c.name                          # => nil
c.duration                      # => nil
c.name = "Woolgathering 101"
c.name                          # => "Woolgathering 101"
c.duration = "2 weeks"
c.duration                      # => Weeks[2]
c.duration = "1 fortnight"
c.duration
# => #<ExceptionalValue:0x00560ac26ad150
#     @raw="1 fortnight",
#     @reason="Unrecognized format">

The duration values for days, weeks, or months represent a domain concept. Likewise, an ExceptionalValue represents the important domain concept of a miss-filled form in a field presented to a user.

But the course name is a string.

A string is, from the point of view of our business domain, a primitive type. If you encountered this string outside the context of its containing Course object, you wouldn't know for sure what it represented. It could be the name of a course, but it could also be the content of snarky comment. Semantically, all the String type implies is "a series of bytes, possibly representing human-readable text".

So how can we bring this field up to the same level of abstraction as the course duration? We need a new kind of Whole Value. Remember that according to the CHECKS paper, a whole value is an object that captures "capture the whole quantity with all its implications".

So what should the type of this field be? What kind of domain information does this field hold?

In this case, the designation of the field itself is the only clue we need. A course name is… a name.

Let's go over to the Course model definition, and add a writer method that echoes the writer method for the duration field. It will run the input string through a conversion function for a new kind of Whole Value: a Name.

Course = Struct.new(:name, :duration) do
  def name=(new_name)
    self[:name] = Name(new_name)
  end

  def duration=(new_duration)
    self[:duration] = Duration(new_duration)
  end
end

Now that we know the type we need, let's define it. But we'll start by laying some groundwork. Since we're going to be working with multiple varieties of Whole Value, let's make a base class for the behavior they share.

One thing we can say for sure about any whole value: since it isn't an actual ExceptionalValue object, then by definition it is unexceptional.

class WholeValue
  def exceptional?
    false
  end
end

From this, we derive a Name class. We give it a constructor that will accept string contents of the name, and freeze it to emphasize that this is a value object.

We also give it a to_s conversion method so that it will display as its contents.

Finally, we add a custom inspection method.

Then we add a definition for the Name conversion function that we used in the Course name writer.

Given a string, returns a Name object. Given something that's already a Name, it passes it through. And given anything else, it raises an error.

class Name < WholeValue
  def initialize(content)
    @content = content
    freeze
  end

  def to_s
    @content.to_s
  end

  def inspect
    "#{self.class}(#{@content})"
  end

end

def Name(content)
  case content
  when String then Name.new(content)
  when Name then content
  else fail TypeError, "Can't make Name from #{content.inspect}"
  end
end

(For a quick refresher on conversion functions, see episode #207.)

While we're at it, we keep things consistent by also deriving the Duration class from WholeValue. Now it too will inherit our base definition of the exceptional? predicate.

class Duration < WholeValue
  attr_reader :magnitude

  def self.[](magnitude)
    new(magnitude)
  end

  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

Now let's get rid of the monkey-patch on Object.

At this point, when we assign a new name to a course object, it gets auto-converted to a Name object.

And this object is capable of telling us whether it is an exceptional value.

require "./models_uniform"

c = Course.new
c.name = "Cookie Eating 201"
c.name                          # => Name(Cookie Eating 201)
c.name.exceptional?             # => false

So that's it, we're done, right?

Well, not quite yet. There's one more primitive value type that we haven't accounted for. But we'll save that for the next episode. Until then: happy hacking!

Responses