In Progress
Unit 1, Lesson 1
In Progress

Substitutable Whole Value

Video transcript & code

In episode #448, we came up with a Whole Value object type to represent a coffee bean origin. Here's an implementation for that class, similar to the Whole Value types we've defined in other episodes.

class BeanOrigin
  attr_reader :value

  def initialize(raw_value)
    @value = raw_value.to_str
    freeze
  end

  def to_s
    value.to_s
  end

  def exceptional?
    false
  end
end

Up until we introduced this class, we used to represent bean origins as plain old strings.

batch = CoffeeBatch.new
batch.bean_origin = "Guatemala Antigua"

But now we've started using the Whole Value representations.

batch = CoffeeBatch.new
batch.bean_origin = BeanOrigin.new("Guatemala Antigua")

(By the way, if you're confused about this term Whole Object and why we're using it, you might want to go check out episodes #400 and #401 before coming back to this one.)

Unfortunately, introducing Whole Values has caused some compatibility problems to crop up. It turns out that there are a number of methods in the system already, which expect a coffee origin to be a primitive string value.

For instance, our order queue doesn't yet use these bean origin objects. It's really very bare-bones: each order is just some text in a regular format.

There's a method that looks up all of the orders for a particular type of coffee bean.

It searched through the order list, looking to find the ones that have that bean origin somewhere in their text.

When we our bean origin is a string, this works great.

ORDERS = [
  "1lb dark Ethiopian Harrar",
  "3lb light Guatemala Antigua",
  "2lb medium Mocha Java",
  "1.5lb light Guatemala Antigua"
]

def orders_for_origin(origin)
  ORDERS.select{|order| order.include?(origin)}
end

origin = "Guatemala Antigua"

orders_for_origin(origin)
# => ["3lb light Guatemala Antigua", "1.5lb light Guatemala Antigua"]

But when bean origins become Whole Values, we run into problems.

require "./bean_origin.rb"
origin = BeanOrigin.new("Guatemala Antigua")

orders_for_origin(origin)
# =>

# ~> TypeError
# ~> no implicit conversion of BeanOrigin into String
# ~>
# ~> xmptmp-in14110SIt.rb:9:in `include?'
# ~> xmptmp-in14110SIt.rb:9:in `block in orders_for_origin'
# ~> xmptmp-in14110SIt.rb:9:in `select'
# ~> xmptmp-in14110SIt.rb:9:in `orders_for_origin'
# ~> xmptmp-in14110SIt.rb:20:in `<main>'

The exception that was raised here is saying that the include? predicate expects a String object, but we gave it a BeanOrigin instead.

Does this mean we have to update our whole system to be aware of BeanOrigin objects before it will work again?

Well, maybe not. Take a closer look at the error message. It says that there is no implicit conversion of BeanOrigin into String.

What's an "implicit conversion"? That's a topic we tackled back in episodes #209 and #210, and I'm not going to rehash everything we covered in those episodes. Instead, today let's just see what it takes to fix this error.

If we take a look at the definition of the BeanOrigin class, we can see that it defines the to_s method. That makes it explicitly convertible to String.

To make it implicitly convertible, we have to also define a to_str method.

class BeanOrigin
  # ...

  def to_str
    # ...
  end

  # ...
end

But before we go through with this, let's consider whether we should. Does it make sense for a BeanOrigin object to have an implicit conversion to String?

Well, the BeanOrigin class is really just a very light semantic wrapper around a String value. So yes, in this case, I'd say it's appropriate for it to be implicitly convertible to a string.

We'll just make the to_str definition forward straight to the explicit to_s conversion method.

require "./bean_origin"
class BeanOrigin
  # ...

  def to_str
    to_s
  end

  # ...
end

Now when we try our coffee order search again, it just works.

ORDERS = [
  "1lb dark Ethiopian Harrar",
  "3lb light Guatemala Antigua",
  "2lb medium Mocha Java",
  "1.5lb light Guatemala Antigua"
]

def orders_for_origin(origin)
  ORDERS.select{|order| order.include?(origin)}
end

require "./bean_origin2.rb"
origin = BeanOrigin.new("Guatemala Antigua")

orders_for_origin(origin)
# => ["3lb light Guatemala Antigua", "1.5lb light Guatemala Antigua"]

Ruby's String#include? predicate has automatically used our to_str method to implicitly convert the BeanOrigin to a raw string, for the purpose of order lookup.

So that fixes one problem. Sadly, this is not the only kind of failure we've been seeing since changing over to the BeanOrigin objects.

What we just saw was a case of a method that used a bean origin as an argument to a message. But that's not the only case we have to worry about.

A bean's origin can contain information both about the region it is from, and the company or grower's collective that grew it. We've evolved a convention of representing this information internally using a comma to separate the region from the grower.

We don't always want to display the information in exactly this way, however. Here's a display helper method we've been using to reformat the presentation of coffee bean origins.

Internally, it sends the #split message to the bean origin object, implicitly assuming that it's a String.

This works fine when the input really is a string.

# coding: utf-8
require "./bean_origin2"

origin = "Peru, CECANOR Café Femenino"

def render_bean_origin(bean_origin)
  region, company = bean_origin.split(",")
  result = region.dup
  result << " (#{company.strip})" if company
  result
end

render_bean_origin(origin)
# => "Peru (CECANOR Café Femenino)"

But it works less well when the origin is a Whole Value.

# coding: utf-8
require "./bean_origin2"

def render_bean_origin(bean_origin)
  region, company = bean_origin.split(",") # ~> NoMethodError: undefined method `split' for #<BeanOrigin:0x005603f1217b18>
  result = region.dup
  result << " (#{company.strip})" if company
  result
end

origin = BeanOrigin.new("Peru, CECANOR Café Femenino")
render_bean_origin(origin)
# =>

# ~> NoMethodError
# ~> undefined method `split' for #<BeanOrigin:0x005603f1217b18>
# ~>
# ~> xmptmp-in14110zNj.rb:5:in `render_bean_origin'
# ~> xmptmp-in14110zNj.rb:12:in `<main>'

There's a distinct difference between this helper method and the last legacy method we looked at. The earlier method supplied the bean origin as an argument to another message. Whereas this method is sending string messages to the bean origin.

OK, how do we make our new objects play nicely with pre-existing methods such as this one?

Well, there's one very quick, very easy fix: we can make the BeanOrigin inherit from String.

This also means we can drastically simplify the class.

class BeanOrigin < String
  def initialize(*)
    super
    freeze
  end

  def exceptional?
    false
  end
end

And now, given an argument which is, in fact, a String, the helper method Just Works.

# coding: utf-8
origin = BeanOrigin.new("Peru, CECANOR Café Femenino")
render_bean_origin(origin)
# => "Peru (CECANOR Café Femenino)"

But is this solution ideal?

By sending the split message, this helper method is treating a bean origin as "dumb primitive data". It's saying that it expects the bean origin to be too stupid to answer questions about itself, and so it just digs around inside the value until it gets what it wants.

region, company = bean_origin.split(",")

By historically using primitives instead of Whole Values, we've allowed structural information about the coffee bean origin concept to be smeared out across the system, including into the display layer. This is exactly the kind of pathological coupling we're seeking to avoid by moving to Whole Object representations.

If we use inheritance to make our objects trivially substitutable for their old primitive counterparts, we toss away any advantages we might have gained from using Whole Values. Methods all across the system will continue to treat them as dumb data, and we'll never be able to safely vary their internal representation.

So in this case, I think we need to push back a little harder. Let's first re-institute our old definition of BeanOrigin.

class BeanOrigin
  attr_reader :value

  def initialize(raw_value)
    @value = raw_value.to_str
    freeze
  end

  def to_s
    value.to_s
  end

  def to_str
    to_s
  end

  def exceptional?
    false
  end
end

Let's say a deadline is looming and we absolutely cannot take the time to safely refactor all of our existing code to respect the boundaries of the new Whole Objects. In that case, we should selectively, and on a case-by-case basis, add temporary shims for backwards compatibility.

In this case, we can add a method that forwards the split message to the internal string representation.

class BeanOrigin
  # ...
  def split(pattern)
    value.split(pattern)
  end
  # ...
end

While we're here, we should mark the method as deprecated so that other programmers know they can't rely on it forever.

If we're using the YARD documentation standard, we could mark it with a @deprecated tag.

class BeanOrigin
  # ...

  # @deprecated bean origins aren't strings anymore
  def split(pattern)
    value.split(pattern)
  end
  # ...
end

Or if nothing else, we could just add some warning output.

class BeanOrigin
  # ...

  def split(pattern)
    warn "Call to deprecated BeanOrigin#split"
    value.split(pattern)
  end
  # ...
end

Eventually, once we have more time, we'll want to move the intelligence about growers and regions into the BeanOrigin class where it belongs.

class BeanOrigin
  attr_reader :value, :region, :grower

  def initialize(raw_value)
    @value           = raw_value.to_str
    @region, @grower = @value.split(",")
    @grower.strip!
    @region.strip!
    freeze
  end

  def to_s
    value.to_s
  end

  def to_str
    to_s
  end

  def exceptional?
    false
  end
end

Once that's done, we can audit our code for calls that still treat bean origins as raw strings, and change them to respect boundaries a little better.

# coding: utf-8
require "./bean_origin5"

def render_bean_origin(bean_origin)
  "#{bean_origin.region} (via #{bean_origin.grower})"
end

origin = BeanOrigin.new("Peru, CECANOR Café Femenino")
render_bean_origin(origin)
# => "Peru (via CECANOR Café Femenino)"

In this episode, I've tried to answer some potential questions about introducing Whole Values to an existing codebase. Yes, there are strategies we can use to ease the transition from primitive values to Whole Values. We can take steps to make the new objects transparently substitutable for the values they are replacing.

But while applying these strategies, we need to remember the goal of introducing Whole Values in the first place. We want to eliminate code that treats discrete domain concepts as primitive computer science types. With that in mind, we don't want to make broad allowances for legacy code to keep interacting with the data the same way it always has.

We should keep our shims and compromises narrow and, whenever possible, short-lived. And we should keep the pressure on to update old code and get rid of the need for those allowances. That way we can fully enjoy the flexibility and safety payoffs of using Whole Values as our system evolves.

Happy hacking!

Responses