TL;DR
Sometimes a set of value objects has its own attributes in addition to the attributes of the individual values themselves.
Background
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 #count
, or #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
A bibliographic example
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 complete?
and 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
Back to the real world
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 complete?
vs incomplete?
, and compatible?
vs 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
Further functionality
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
Top comments (0)