In computer science, a value object is a small object that represents a simple entity whose equality is not based on identity: i.e. two value objects are equal when they have the same value, not necessarily being the same object. →
Creating good, reusable and idiomatic value objects for Ruby is not that simple.
This repository provides a checklist for a good value object design. Currently it is in "RFC" (request for comments) state, gathering experience, agreements and convention. In future, it will also have automated tests, so you can just
# in RSpec
it_behaves_like "good value object",
arithmetic: false,
ordered: false,
sample_values: [
{lat: 1, lng: 2},
{lat: 50, lng: 40}
...
]
We are using imaginary, yet real-life-alike Quantity { amount: Numeric, unit: String }
type for most of the examples. And, eventually, other types that demonstrate some points better.
We can think about most value objects as a Struct (not Ruby's particular implementation, but generic programming concept: group of named fields). The fields of this structure we further will call structural elements. It is logical concept rather than implementational.
Example: for Date
value type, "structural" values are probably (year, month, day of month)
(maybe calendar
too, depending of fanciness of your date). That does not imply that Date
instance stores them in instance variables, neither the fact that it is the only instance variables:
2018, March, Monday
is ambiguous, and 2018, March, 5th
doesn't need weekday to be specific).Note:
2018, 10th week, Monday
is a thing in some business contexts, but probably it is better to have specialized constructor or even type for it.
#initialize
should have type's structural parts as an arguments, the TypeName.new(...)
should be the most straightforward ("just validate and store in instance variables") way to construct value; all other ways to construct should go to specialized class methods
# Bad
Quantity.new('10 m')
# Good
Quantity.new(10, 'm')
Quantity.parse('10 m')
# Probably OK
IPAddress.new('192.168.0.1')
# Does it bring additional clarity? YMMV
IPAddress.new(192, 168, 0, 1)
# Please don't!
IPAddress.new(byte1: 192, byte2: 168, byte3: 0, byte4: 1)
It is acceptable to have structural elements converted or wrapped on construction
q = Quantity.new(10, 'm')
q.amount # => #<BigDecimal 10>
q.unit # => #<Quantity::Unit m>
Prefer keyword arguments over positional ones in most cases, especially if there are more than 2 arguments for constructor, or order is not obvious (is it GeoPoint.new(lat, lng)
or GeoPoint.new(lng, lat)
?)
params = {}
hack)Value construction options could be provided by keyword arguments, but it is undesirable to have both main argument and options as keyword arguments, or having both as positional arguments
# OK
Quantity.new(10, 'm')
Quantity.new(amount: 10, unit: 'm')
Quantity.new(10, 'm', system: Quantity::SI)
# Questionable
Quantity.new(amount: 10, unit: 'm', system: Quantity::SI)
Quantity.new(10, 'm', Quantity::SI)
Sometimes it is useful (but not required) to provide construction method synonymous with the type name, e.g. Quantity(amount, unit)
; it brings no additional functionality yet emphasizes the fact that value "just exists", and we are referencing to existing concept of "10 meters", not constructing it (which "new" implies);
{Type}.call
class method: it allows to have almost the same look-and-feel, yet semantically belongs to the same module instead of being a global method:# Shortcut for .call, available since Ruby 1.9
Quantity.(10, 'm')
# Some prefer this alternative:
Quantity[10, 'm']
If there are expected to be a lot of similar objects created during the lifecycle of the application, consider caching objects (having exactly one object for one value). Type.new
can be redefined for this purpose:
10.times.map { Quantity.new(10, 'm') }.map(&:object_id).uniq.count # => 1
Another approach seen in use is making Type.new
private, and making Type(...)
or Type.[]
(with caching inside) the primary construction method.
Avoid redefining .new
for other purposes, especially to return value of type different from requested:
# Really bad
Quantity.new(10, 'm') # => #<Quantity::Physics::Length 10 m>
# Something like this would be better
Quantity.coerce(10, 'm') # => #<Quantity::Physics::Length 10 m>
# or even
Quantity['m'].new(10)
All structural elements of the value should be exposed as attr_reader
s (or methods with the same behavior)
Value object should be absolutely immutable, no attr_writer
s and no other way to change value of the object
freeze
all structural elements that belong to mutable Ruby types, to prevent code like this:
q = Quantity.new(10, 'm')
q.unit.upcase!
# Or, more believable:
q = Quantity.new(10, 'm')
u = q.unit
# ...later...
u.upcase! # => Unexpectedly makes q to have unit == 'M'
As immutability makes this code impossible:
new_value = value.dup
new_value.property = x
consider providing some reasonable methods to "produce a value like this, with some parts changed"
merge(property: value, property: value)
interface for it
# Good
FancyDate.now.merge(month: 12) # produces new FancyDate: "same day, but in December"
# Not really useful
Quantity.new(10, 'm').merge(unit: 's') # what's the semantics of "same value but in seconds"?..
# Probably better
Quantity.new(10, 'm').unit.create(20) # => Quantity(20, 'm')
#with
is another frequently used option instead of #merge
.No global option should change behavior of value objects. Consider providing "context" or "environment" to constructor or instance method:
# Unforgivable bad
Quantity.new(10, 'm').normalize # => Quantity.new(32.8, 'feet')
Quantity.system = Quantity::SI
Quantity.new(10, 'm').normalize # => Quantity.new(10, 'm')
# Still pretty questionable
Quantity.new(10, 'm').normalize # => Quantity.new(32.8, 'feet')
Quantity.new(10, 'm', system: Quantity::SI).normalize # => Quantity.new(10, 'm')
# Good
Quantity.new(10, 'm').normalize # => Quantity.new(32.8, 'feet')
Quantity.new(10, 'm').normalize(system: Quantity::SI) # => Quantity.new(10, 'm')
# Best ;)
Quantity.new(10, 'm').normalize # => Quantity.new(10, 'm')
Quantity.new(10, 'm').normalize(system: Quantity::IMPERIAL) # => Quantity.new(32.8, 'feet')
#inspect
and #pp
#inspect
for your types, it is really helpful for debugging#inspect
for value types should look like #<TypeName value representation>
# Good
Quantity.new(10, 'm').inspect # => "#<Quantity 10 m>" or #<Quantity(10 m)>`
# Bad
Quantity.new(10, 'm').inspect # => "#<Quantity(m)>"
Quantity.new(10, 'm').inspect # => "10 m" - it is unhelpful to not be able to distinguish from string while debugging
Quantity.new(10, 'm').inspect # => "#<Quantity amount=10 unit=\"m\">" - unnecessary verbosity
# Also bad: Ruby's stdlib Date
Date.today.inspect # => "#<Date: 2018-03-04 ((2458182j,0s,0n),+0s,2299161j)>" -- ((2458182j,0s,0n),+0s,2299161j) anybody?
#inspect
should try hard to never raise and never return anything except string
# Good
Quantity.new(INFINITY, 'm') # => ArgumentError on attempt to create, no problems with inspect
# Acceptable
Quantity.new(INFINITY, 'm').inspect # => "#<Quantity [UNREPRESENTABLE]>"
# Bad
Quantity.new(INFINITY, 'm').inspect # => ArgumentError or nil
# Not really helpful
Quantity.new(10_000_000, 'm').inspect # => #<Quantity 10000000 m>
# Good
Quantity.new(10_000_000, 'm').inspect # => #<Quantity 10,000,000 m>
# Could be acceptable in some contexts
Quantity.new(10_000_000, 'm').inspect # => #<Quantity 1e7 m>
pp
is required by default, consider implementing multiline #pretty_print
for the value, especially if it contains lots of data that is reasonable to print in multiple lines
#pretty_print
(pretty terse, yet enough to start) could be found here
==
method for values
# example of the latter:
Quantity.new(1000, 'm') == Quantity.new(1, 'km') # => probably true, unless the domain is some formal reporting system
==
should NOT raise on attempt to compare with incompatible type: in Ruby, 1 == "1"
is just false
, not a deadly sin punished by exception# Good
Quantity.new(1, 'm') == Unitwise(1, 'm')
Date.parse('2017-05-01') == Time.parse('2017-05-01') # Doesn't work in Ruby though ;)
# Bad
Quantity.new(10, 'm') == 10 # could be helpful in some particular case yet source of hidden bugs
#eql?
#equal?
<
, >
and so on) if, and only if, order on all acceptable values is defined and unambiguous
<=>
and including Comparable
(and it will give you ==
for free)<=>
should NOT raise on attempt to compare with incompatible type, just return nil
, Comparable
s implementation of other method will behave the most reasonable way: ==
will return false
and <
and other similar methods would raise ArgumentError
<
and >
by yourself, don't forget about <=
and >=
; and make them raise ArgumentError
on incompatible typespositive?
, negative?
and zero?
for the value if, and only if, their meaning is clear and semantically unambiguousType::INFINITY
constant or class method, for using in expressions like:
ranges = {
Quantity.new(1, 'm')...Quantity.new(10, 'm') => 'near',
Quantity.new(10, 'm')...Quantity.new(100, 'm') => 'far',
Quantity.new(100, 'm')...Quantity::INFINITY => 'nowhere'
}
ranges.select { |r, _| r.cover?(value) }....
# and this
value.clamp(Quantity.new(100, 'm'), Quantity::INFINITY) # "not lower the 100" one-side clamp
Possible infinity concept interfaces:
# Probably OK if used rarely, and constructor should not fail on this
Quantity.new(Float::INFINITY, 'm')
# Pretty clear yet no explicit type, can be hard to implement <=>
Quantity::INFINITY
# Also clear and typed, needs mindful implementation
Quantity.infinity('m')
+
, -
, *
, /
and so on) if their meaning is obvious and unambiguousa + b == b + a
, a - b = a + (-b)
and so on)
+
for concatenation (of strings and arrays), which is not commutative~Quantity.new(10, 'm')
to say "something about this quantity" (for example, producing range Quantity.new(9.5, 'm')..Quantity.new(10.5, 'm')
) is witty yet leads to unguessable code|
and &
if:
Dates::Period.parse('2017-02') | Dates::Period.parse('2016-12')
# => #<Dates::Period Dec 1-31 2016, Feb 1-28 2017>
Dates::Period.parse_range('2017-01-30'..'2017-02-12') & Dates::Period.parse('2017-01')
# => #<Dates::Period Jan 30-31 2017>
===
if value can be used as some kind of pattern
# Messy
if quantity.unit == 'm'
elsif quantity.unit == 's'
else ...
quantities.select { |q| q.unit == 'm' }
# Nice
case quantity
when Quantity::Unit('m')
when Quantity::Unit('s')
...
quantities.grep(Quantity::Unit('m'))
#to_<type>
to convert value object to other types#to_<type>
protocol should be used only when format or precision of value is changed, but not when context is lost
# Good
BigDecimal('100').to_i # => it is the same number, just loses precision
# Bad
Quantity.new(10, 'm').to_i # => context is lost, Quantity#amount is much better convention
# Acceptable
Dates::Period.to_activercord # => may have sense in some context
# Questionable
Dates::Period.to_regexp # => probably, just #regexp would be better
#to_str
, #to_ary
, #to_hash
, #to_int
) unless you really know what you do (= type is really kind of string/array/hash/integer); they'll convert values violently and unexpectedly;to_a
either (unless it is kind of collection), as it will unexpectedly deconstruct the value on Array(value)
call
Struct
, you should explicitly undef :to_a
#each_<something>
methods, returning Enumerator
#to_h
, it is really good for serialization:
#to_h
should probably return hash with symbolic keys, containing exactly all the structural elements of value object and nothing more;ValueType.new(**value.to_h) == value
should be always true#to_s
, as Ruby's default #to_s
will expose object_id and look really unhelpful on string interpolations
#to_s
as possible, without any quoting and type names;#to_s
just an alias to #inspect
(see "#inspect
and #pp
" section above)# Good
puts Quantity.new(10 'm') # "10 m"
# Also good
puts StoreId.fetch('xyz') # => "#<StoreId xyz>"
# Questionable
puts StoreId.fetch('xyz') # => "xyz" -- Loses too much of domain context
# if you needed this to interpolate sql, probably #to_sql method would be better
#format(lot: of, **options)
or strf<typename>
Type.from_<othertype>()
methods for as much of basic Ruby types, and domain types, as possible;to_<othertype>
, the from_
naming convention can ONLY be used if format or precision of data is changed, but not when context is lost or attached:# Good
Quantity.from_a([10, 'm']) # => #<Quantity 10 m>
# Bad
Quantity.from_f(10, unit: 'm') # It is constructor (maybe specialized one), not "converter from Float"!
Type.from_othertype(value.to_othertype) == value
should be true
;nil
:Quantity.from_a([10, 'm']) # => #<Quantity 10 m>
Quantity.from_a([10]) # => ArgumentError: expected 2-element array
Quantity.try_from_a([10]) # => nil
Typename.parse(string)
that accepts any input, tries to guess how to parse it, and returns nil
if it absolutely can not;# set of methods:
Quantity.amount_unit('10m') # => #<Quantity 10 m>
Quantity.unit_amount('$10') # => #<Quantity 10 $>
# set of options
Quantity.from_s('$ 10', order: :unit_amount, separator: ' ')
# pattern DSL
Quantity.strpquantity('%amount (%unit)', '20 (m)')
Note: strp<typename>
is probably not the best convention, but it is like Ruby's Date.strptime
#hash
, returning different number for different combinations of structural elements, and same number for same combination. The easiest implementation is probably
def hash
[each, of, structural, elements, self.class].hash
end
#eql?
method also should be implemented, as Hash uses it to decide on key's equality on #hash
values collision (as number of possible integer values could be lower than number of possible value object values). Typically, it can be just an alias to #==
, but if #==
is forgiving, #eql?
should be strict.
# Imagine Paragraph class, which is just a wrapper around String, but with some fancy interface
# It can have...
def ==(other)
@string == other.to_s
end
# In this case...
h = {'test' => 1, Paragraph.new('test') => 2}
# ...may lead to only ONE key being stored
#eql?
implementation is different from #==
's, never implement it as a #hash
comparison
# Really bad: on hash collision Hash will have no means of telling two values one from other
def eql?(other)
hash == other.hash
end
For most of its functionality, Ruby's Range
currently relies on value providing #succ
(next value in ordered values space). Unfortunately, this includes case equality ===
too.
UPD: Since Ruby 2.6, #===
uses cover?
underneath, so "not working" or "too slow" examples of case
below are working correctly. So, rule about implementing #succ
becomes simpler: "Implement it, if it makes unambigous sense for your type".
For code expecting to work under Ruby < 2.6, there stay two opposite rules:
#succ
method if value space is small and has unambiguous granularity, to allow code like this:
case DayOfWeek.current
when DayOfWeek('Mon')..DayOfWeek('Thu')
#succ
to explicitly disallow code like this:
# Idiomatic, yet slow: calculates thousands of IPs inside range
case ip
when IP("172.16.10.1")..IP("172.16.11.255")
...
# Can't be used in `case`, yet fast:
if (IP("172.16.10.1")..IP("172.16.11.255")).cover?(ip) ...
# Another case
#
# Ruby will try to do #succ on start value, but what it should be?
# "Obvious" from the first sight Quantity.new(2, 'm') will leave Quantity.new(1.5, 'm') outside the comparison
case quantity
when Quantity.new(1, 'm')..Quantity.new(10, 'm')
...
# The only solution, again:
if (Quantity.new(1, 'm')..Quantity.new(10, 'm')).cover?(quantity)
#to_json
implementation. For lot of cases, this should be enough (if you have provided #to_h
which is strongly advised above):
def to_json(*opts)
to_h.to_json(*opts)
end
YAML.dump
, and just set them all to an uninitialized allocated object on YAML.load
. You can alter this behavior by redefining methods with inventive and memoizable names encode_with(coder)
and init_with(coder)
# good
- !ruby/object:Quantity
amount: 1
unit: m
# not so good
- !ruby/object:Quantity
amount: 1
unit: !ruby/object:Quantity::Unit
name: meter
synonym: metre
plural: metres
short: m
domain: distance
base: true
system: !ruby/object:Quantity::System
...
_memoized_method_cache_: # memoist was here....
...
For small value objects it is always a temptation to inherit from, to add several more methods, change constructor or formatting, required by current domain. Your types should be ready to be inherited, which most of the time, means not hardcoding class (by name or by value) in methods (or, sometimes, vice versa, hardcoding it, look at examples)
class FancyQuantity < Quantity
end
# Bad
FancyQuantity.new(10, 'm').inspect # => #<Quantity 10 m>, because #inspect hardcodes "#<Quantity" part
# solution is
def inspect
"#<#{self.class} .... >"
end
# Probably bad
FancyQuantity.new(10, 'm') == Quantity.new(10, 'm') # => false, because #== has self.class == other.class
# solution?
def ==(other)
# Bad: only values of exactly same type are compatible
self.class == other.class && ...
# Bad: Quantity#==(FancyQuantity) would work, but not vice versa
other.kind_of?(self.class) && ...
# Good: just hardcode the base
other.is_a?(Quantity) && ...
# ...or, sometimes, duck type
other.respond_to?(:amount) && other.respond_to?(:unit) && ...
end
Value objects are a prime candidate for use with the pattern matching syntax introduced in Ruby 2.7.
The easiest way to integrate with pattern matching is to return a hash of attributes from #decontruct_keys
. The simplest implementation is to just call #to_h
, if that method is already available. This allows the object to be pattern matched using the hash syntax:
class Quantity
...
def decontruct_keys(_keys)
to_h
end
end
case Quantity.new(10, 'm')
in unit: 'm', amount:
puts "Metric: #{amount} meters"
in unit: 'ft', amount:
puts "Imperial: #{amount} feet"
end
The _keys
argument provided to #decontruct_keys
can be ignored, but if generating the returned hash is an expensive operation, this argument can be used to optimize performance.
Consider also supporting the array syntax if the attributes have an obvious or intuitive ordering (See also: keyword arguments vs positional arguments in Construction). This is done by returning an array of attributes from the #deconstruct
method:
class Quantity
...
def deconstruct
[@amount, @unit]
end
end
case Quantity.new(10, 'm')
in amount, 'm'
puts "Metric: #{amount} meters"
in amount, 'ft'
puts "Imperial: #{amount} feet"
end