Whole Value (Value Object)

Posted Over 7 years ago. Visible to the public. Draft.

We’re writing software for managing a coffee shop.

class CoffeeBatch
  attr_reader :bean_origin, :roast_level, :roast_date

  def initialize(bean_origin:, roast_level:, roast_date:)
    @bean_origin = bean_origin
    @roast_level = roast_level
    @roast_date  = roast_date
  end
end

Rather than representing the attributes of domain objects as “primitive” types, we’re going to use types which encapsulate the full semantic meaning of each bit of information. So far, our CoffeeBatch class just has three attributes:

  1. A bean_origin to note where these particular beans came from.
  2. A roast_level, to indicate whether this is a dark, medium, or light roast.
  3. And a roast_date, tagging this particular batch with the day it was roasted on.

Now, let’s instantiate our first CoffeeBatch, and give it some values.

require "date"
 
CoffeeBatch.new(
  roast_date:  Date.parse("2016-08-30"),
  roast_level: RoastLevel.new("medium"),
  bean_origin: BeanOrigin.new("Peru CECANOR Café Femenino")
)

We’ll get around to actually defining the RoastLevel class later. Right now, it’s enough to know that it’s a discrete concept that exists in our domain. It would be easy to look at this and think that I’m suggesting we need to create a new Whole Value type for every single attribute of every single domain model. But that’s not quite what I’m getting at. We shouldn’t blindly name each class after the attribute name. After all, we decided that an ordinary Date was sufficient to represent the roast_date attribute.
But when we’re in doubt; when we’re not sure what class to use, it’s OK to start with a class that’s specific to that one attribute, and then broaden as we learn more. We’re letting the context of the value dictate how we model it. Instead of trying to find the right type in an objective hierarchy of all possible things, we’re basing our naming on the role being played.

Here’s an implementation for BeanOrigin class:

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. 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.

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>'

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.

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
  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

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"]
Alexander M
Last edit
Over 7 years ago
Alexander M
Posted by Alexander M to Ruby and RoR knowledge base (2016-10-09 15:34)