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:
bean_origin
to note where these particular beans came from.roast_level
, to indicate whether this is a dark, medium, or light roast.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"]