Getting the Most Out of Ruby Value Objects (5 Part Series)
Sometimes a set of value objects has its own attributes in addition to the attributes of the individual values themselves.
When we have a single value and we want to infer behaviour from it, for example when we have country codes and we want to be able to read the continent name, the currencies, or the key of the national anthem, then a value object is probably what we're looking for.
And when we have a set of such single values, such as a list of countries to be visited in a trip, then we also want to send messages to that list of countries.
At the simple end, we might have
#uniq_count if countries can be present more than once,
currencies (expecting a list of the currencies for all of the countries in the list to be returned), etc..
Quite often there are issues of compatibility or completeness involved in a set, and the code for detecting these issues needs a home.
That home is probably a value object.
class Countries def initialize(*countries) @countries = Array.wrap(countries) end delegate :count, :any?, :empty?, :entries, to: :countries def currencies countries.map(&:currencies) end def uniq_count countries.uniq.count end def compatible? etc end def closed_loop? countries.first == countries.last end end
Consider an example: a code list that we at Consonance use a lot, the Thema subject categories for book publishing. A publisher assigns multiple codes to a book to inform third parties, such as sales agents, other publishers, distributors, retailers, and ultimately the potential buyer, what it is about and who might be interested in it.
You can see by browsing the list that there is a huge range in the subjects coding, reflecting that some books are intended for professional or academic practitioners, for children, for people with a general interest in pets, and there are also codes for place, time, and special themes (birthdays, seasonal holidays etc).
Many of these can be combined for a single book, and we expect all of them to be individually valid of course.
But some further questions can arise when you consider all of the codes assigned to a book:
- Are codes being used which suggest that others should also be present, and if so are those others actually present?
- Do children's books have an age range?
- Do law books contain a geographical restriction? In general every professional law book relates to a particular geographical region.
- Is a book about archaeology also coded for both time and place?
- Are there codes being used which are possibly not compatible with each other?
- Is a book about a scholarly subject also coded as being of interest to children under five years of age?
- Is a book coded for general interest also coded for professional interest?
So individual codes can each be valid, while the combination of them can be problematic in a number of ways.
The important point here is that these
valid? attributes are of the set of codes, not of the individual codes themselves, and might be implemented as:
class ThemaSet def initialize(*set) @set = set end delegate :any?, to: :set def errors errors_of_incompatibility + errors_of_incompleteness end private def childrens? any?(&:childrens?) end private def professional_or_scholarly? any?(&:professional_or_scholarly?) end private def historical? any(&:historical?) end private def child_incompatible? childrens? && (professional_or_scholarly? || adult_themed?) end private def missing_time? !timed? && historical? end private def missing_place? !placed? && (historical? || legal?) end private def errors_of_incompatibility .tap do |errors| errors << "codes incompatible with child coding are present" if child_incompatible? end end private def errors_of_incompleteness .tap do |errors| errors << "codes should be accompanied by time coding" if missing_time? errors << "codes should be accompanied by place coding" if missing_place? end end
So the pattern there is reasonably clear, I think.
The set-value object examines the attributes of the individual value objects in order to produce characterisations of the set as a whole.
Although the example given was quite specific to a business area, this pattern of
incompatible?, leading to attributes of
valid? and a list of
errors is a common one.
And this makes it amenable to the use of the ValidValidator described here
In many cases the individual objects will not be plain value objects, but might be derived from an ActiveRecord-backed
has_many association. But if the set of objects has its own attributes, then that is still a candidate for this technique.
class Book has_many :thema_codes def thema_set @_thema_set ||= ThemaSet.new(thema_codes) end delegate :valid?, :errors, to: :thema_set, prefix: true end
A variation on this can be used to answer the question, “what is the effect of a particular change to a set?”.
Given a current set and a candidate member of it, you can compare the original against the original + candidate-value, and ask of it "have I changed a property of the set by making this change, such as introducing (or solving) any errors?"
And the implementation of that is reasonably clear:
class SetComparator def initialize(set_object, new_value) @original_set = set_object @new_set = set_object.class.new(set_object.entries << new_value) end def errors_added new_set.errors - original_set.errors end def errors_removed original_set.errors - new_set.errors end end