Posted about 5 years ago. Visible to the public. Draft.

Whole Value (Value Object)

We’re writing software for managing a coffee shop.

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

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

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

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

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

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

Copy
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"]

Owner of this card:

Avatar
Alexander M
Last edit:
about 5 years ago
by Alexander M
Tags:
Software-Architecture
Posted by Alexander M to Ruby and RoR knowledge base
This website uses short-lived cookies to improve usability.
Accept or learn more