Value Objects Explained with Ruby
This article explains the concept of value objects. It first defines and demonstrates various kinds of value objects, then it explains the rule to construct valid ones while covering the consequences of violating the concept. At last, it shows several ways to implement value objects in Ruby.
Although the examples are written in Ruby, the concept could be easily applied to other languages as well.
What is a Value Object?
A value object as defined in P of EAA is:
…their notion of equality isn’t based on identity, instead two value objects are equal if all their fields are equal.
That means value objects which have the same internal fields must equal to each other. The value of all fields sufficiently determines the equality of a value object.
The simplest examples are the primitive objects – Symbol
, String
, Integer
, TrueClass
(true
), FalseClass
(false
), NilClass
(nil
), Range
, Regexp
etc. The value of each of these objects determines their equality. For example, whenever 1.0
appears in a program, it should be the equal1 to 1.0
because they have the same value.
var1 = :symbol
var2 = :symbol
var1 == var2 # => true
var1 = 'string'
var2 = 'string'
var1 == var2 # => true
var1 = 1.0
var2 = 1.0
var1 == var2 # => true
var1 = true
var2 = true
var1 == var2 # => true
var1 = nil
var2 = nil
var1 == var2 # => true
var1 = /reg/
var2 = /reg/
var1 == var2 # => true
var1 = 1..2
var2 = 1..2
var1 == var2 # => true
var1 == [1, 2, 3]
var2 == [1, 2, 3]
var1 == var2 # => true
var1 == { key: 'value'}
var2 == { key: 'value'}
var1 == var2 # => true
These are examples of value objects with one field.
Value objects can also be composed of multiple fields. For example, the IPAddr
class in the standard library has three fields, @addr
, @mask_addr
and @family
. @addr
and @mask_addr
define the IP address values and @family
determines its type as IPv4 or IPv6. IPAddr
objects which have the same field values are equal to each other.
require 'ipaddr'
ipaddr1 = IPAddr.new "192.168.2.0/24"
ipaddr2 = IPAddr.new "192.168.2.0/255.255.255.0"
ipaddr1.inspect
# => "#<IPAddr: IPv4:192.168.2.0/255.255.255.0>"
ipaddr2.inspect
#=> "#<IPAddr: IPv4:192.168.2.0/255.255.255.0>"
ipaddr1 == ipaddr2 # => true
Similarly, money, GPS data, tracking data, date range etc. are all proper candidates for value objects.
The above examples demonstrate the definition of value objects – objects whose equality is based on their internal fields rather than their identity.
To guarantee value objects with the same fields will be equal to each other whenever it appears in a program, there is an implicit rule to follow when constructing values objects.
Rule to Construct Value Objects
The rule to guarantee the equality of value objects across their life cycle is: the attributes of a value object will remain unchanged from instantiation to the last state of its existence. “…this is required for the implicit contract that two value objects created equal, should remain equal.”2 Following this rule, value objects should have an immutable interface.
Sometimes, the need to create variants of value objects might break the rule, if they are constructed without careful implementation. Take the following Money
class, for example.
class Money
attr_accessor :currency, :amount
def initialize(amount, currency)
@amount = amount
@currency = currency
end
end
usd = Money.new(10, 'USD')
# <Money:0x007f987f283b50 @amount=10, @currency="USD">
usd.amount = 20
usd.inspect
# <Money:0x007f987f283b50 @amount=20, @currency="USD">
The usd
money value object is changed during its life cycle due to the changes to its @amount
field.
The public setter method amount=
violates the rule because, when it is called, the value shifts from the object’s original one.
The correct approach to create variants of value objects is to implement the setter method to initialize a new value object instead of modifying the current one:
class Money
# remove the public setter interface
attr_reader :currency, :amount
def initialize(amount, currency)
@amount = amount
@currency = currency
end
# a setter method to return a new value object
def amount=(other_amount)
Money.new(other_amount, currency)
end
end
usd = Money.new(10, 'USD')
usd.inspect
# <Money:0x007f9672753ba8 @amount=10, @currency="USD">
other_usd = (usd.amount = 20)
usd.inspect
# <Money:0x007f9672753ba8 @amount=20, @currency="USD">
In this way, once a Money
value object is created, it will remain in its initial state throughout its life cycle. New variants are created as different value objects instead of making changes to the original one.
How to Implement a Value Object in Ruby
In review, to implement a value object following the above definition and rules:
- Value objects have multiple attributes
- Attributes should be immutable througout its life cycle
- Equality is determined by its attributes (and its type)
We’ve already seen the implementation of Money
value objects with Ruby normal class syntax. Let’s complete the implementation by adding methods for determining equality.
class Money
def ==(other_money)
self.class == other_money.class &&
amount == other_money.amount &&
currency == other_money.currency
end
alias :eql? :==
def hash
[@amount, @currency].hash
end
end
usd = Money.new(10, 'USD')
usd2 = Money.new(10, 'USD')
usd == usd2 # => true
eql?
and ==
are standard Ruby methods to determine equality at the object level. By the definition of value objects, the comparison results of all the fields are tested. It is also required to distinguish Money
from other objects which may have the same attributes.
For example:
AnotherMoney = Struct.new(:money, :currency)
other_usd = AnotherMoney.new(10, 'USD')
usd == other_usd # => false
That is accomplished by the line self.class == other_money.class
.
The hash
method is the standard Ruby method to generate a hash value for an object. From the Ruby docs, “…this function must have the property that a.eql?(b)
implies a.hash == b.hash
.” So, the implementation uses all the field values to generate the hash.
Besides the normal class syntax, Struct
as shown in the last example is a very concise way to build value objects. Here is an example to implement the same Money
value objects using Struct
:
class Money < Struct.new(:amount, :currency)
def amount=(other_amount)
Money.new(other_amount, currency)
end
end
usd = Money.new(10, 'USD')
usd2 = Money.new(10, 'USD')
usd.hash == usd2.hash # => true
usd == usd2 # => true
It is much more concise than the normal class definition. Attributes are declared through the Struct.new
interface. Definition of hash
and ==
methods are omitted because they are inherited from the Struct
class.
However, one drawback of using Struct
to define value objects is that they are mutable through the default setter methods and they allow default attribute values.
usd = Money.new(10, 'USD')
usd.amount = 20
usd.inspect
# => <struct Money amount=10, currency="USD">
invalid_usd = Money.new(1)
invalid_usd.inspect
# => <struct Money amount=1, currency=nil>
To keep the conciseness of the Struct
-way, but fix its problems, we could use the Value
gem which is a Struct
-like class with all the above problems fixed. Here is an example from the library’s demo page:
Point = Value.new(:x, :y)
Point.new(1)
# => ArgumentError: wrong number of arguments, 1 for 2
# from /Users/tcrayford/Projects/ruby/values/lib/values.rb:7:in `block (2 levels) in new
# from (irb):5:in new
# from (irb):5
# from /usr/local/bin/irb:12:in `<main>
p = Point.new(1, 2)
p.x = 1
# => NoMethodError: undefined method x= for #<Point:0x00000100943788 @x=0, @y=1>
# from (irb):6
# from /usr/local/bin/irb:12:in <main>
Now, working with value objects in Ruby should be easy and fun.
Conclusion
Starting with definition of value objects, this article shows the usage of value objects from primitive object to more complex domain-specific objects. It also dives into the implicit rule for consistent behavior of value objects during its life cycle.
At last, we’ve gone through several different ways to implement the concept of values objects using Ruby’s normal class definition and Struct
class. Finally, we ended up with a useful Values
gem to create value objects with ease and conciseness.
Does the explanation of value objects provide you inspiration for writing more elegant code? What’s your opinions towards the usage of value objects?
References
Value Object on Wikipedia.
Some discussion on value objects at c2.com.