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.
So far, our
CoffeeBatch class just has three attributes:
bean_originto note where these particular beans came from.
roast_level, to indicate whether this is a dark, medium, or light roast.
- 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.
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
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
Or perhaps a
So perhaps the term we're looking for is "Region" rather than "Nationality".
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
???.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
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
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
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.