In Progress
Unit 1, Lesson 21
In Progress

Specific Whole Value

Video transcript & code

Today 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

We've recently been convinced of the advantages of using the Whole Value pattern in our domain models. So 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.

(By the way, if you're not sure what the Whole Value the is or why we would use it, you might want to review episodes 400 and 401 before continuing on.)

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.

CoffeeBatch.new(

)

Let's take these in opposite order to how we defined them. First up, roasting date.

Do we need a special Whole Value class for roasting dates? Well, this is a judgment call. But remember some of the forces that drove our original extraction of Whole Value in episode #400. We found that it wasn't possible to accurately parse input, perform basic validation, or render values without being aware of the attribute the value was attached to.

In this case, it seems like a reasonably safe bet that the system can both parse input dates, and render dates in an appropriately localized way, without actually knowing that the date in question is a roasting date. The mere fact that it's a date, rather than, say, an integer count of seconds, should make the value sufficiently semantically self-contained.

With that in mind, we decide to initialize this attribute to one of Ruby's standard Date objects, instead of wrapping it in a more specific Whole Value.

require "date"

CoffeeBatch.new(
  roast_date: Date.parse("2016-08-30"),
)

Now let's move on to the roast_level attribute.

If we simply assigned a primitive string naming the type of roast…

require "date"

CoffeeBatch.new(
  roast_date:  Date.parse("2016-08-30"),
  roast_level: "medium",
)

…that would seem to violate the guidelines we've developed for making our attributes hold Whole Values. The string ="medium"= by itself does not convey a lot of context. Medium what? Medium latte? Medium coffee funnel? Medium grind?

This ambiguity is likely to cause problems down the road. For this attribute, we make and assign a RoastLevel object, with the string describing the <>roast level as its only argument.

require "date"

CoffeeBatch.new(
  roast_date:  Date.parse("2016-08-30"),
  roast_level: RoastLevel.new("medium"),
)

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.

Now we come to bean_origin. And this is where things get a little more tricky.

The origin of a particular bag of coffee beans is a very important concept in the coffee shop domain. And we know, roughly, what kind of information this attribute will hold. We can look around any given coffee house and see coffee with sources like "Indonesia", "Ethiopia", or "Guatemala".

require "date"

CoffeeBatch.new(
  roast_date:  Date.parse("2016-08-30"),
  roast_level: RoastLevel.new("medium"),
  bean_origin: "Guatemala"
)

So is the Whole Object here a Country?

Country.new("Guatemala")

Or perhaps a Nationality?

Nationality.new("Guatemala")

If we spend a little longer looking through coffee catalogs, we might notice more granular coffee sources, like "Guatemala Antigua" vs "Guatemala Huehuetenango".

So perhaps the term we're looking for is "Region" rather than "Nationality".

Region.new("Guatemala Huehuetenango")

But then we run across a bag of coffee which says it comes from "<>Peru CECANOR Café Femenino".

Region.new("Peru CECANOR Café Femenino")

This isn't just a country or a region. It's a specific women's only subset of a particular fair-trade growers cooperative operating in one coffee-growing region of Peru.

Now we don't know what type of object should be assigned to bean_origin attributes!

???.new("Peru CECANOR Café Femenino")

We're pretty sure that CoffeeProducingEntity isn't an accepted domain term.

CoffeeProducingEntity.new("Peru CECANOR Café Femenino")

Should we just throw up our hands, and make it a generic Name type?

require "date"

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

That doesn't really seem accurate, nor does it seem to encapsulate the full domain meaning of the concept we're trying to represent here. At this point we might as well just go back to using a plain String.

What we've just been doing is an activity that comes very naturally to programmers: we've been climbing up the ladder of abstraction, trying to find a term sufficiently broad that it encompasses all possibilities.

I suggest that instead, we do something a little counter-intuitive. It's an approach that I learned from Sandi Metz. It says that when in doubt, instead of trying to identify the highest level of abstraction, we should start specific. And then broaden our terms if and when we need to.

What is the most specific description of the concept at hand?

It's right in front of us: it's BeanOrigin.

require "date"

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

In effect, 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.

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. That's a lot easier, and less prone to backtracking, than when we attempt to identify the broadest possible abstraction with limited information.

Maybe, someday, we'll need to be able to link coffee batches with local handicrafts that come from the same region as the coffee beans. On that day, perhaps we'll come up with some kind of more general "origin" abstraction that can be shared across multiple domain models.

But for right now, BeanOrigin captures all the semantics we need for it to capture.

Happy hacking!

Responses