In the last couple of weeks I've repeatedly stumbled over a smell in a project I'm working on: the classifier of an object was inflected by the prefix of its name. As an analogy, think of the ISBN code, where groups of numbers have a special meaning.

This is a classic example of the Value Object pattern, which has received quite some press, lately. Most prominently Martin Fowler published a post, where he provides a great introduction to the topic, as well as implementation details for JavaScript (which has the issue that you cannot override equality methods) and Java.

Looking at Ruby, Luca Guidi, who is the creator of Hanami (formerly Lotus), has released a series of two posts on the DNSimple blog (part 1, part 2).

I invested some time last week and implemented a value object (I took the ISBN example from above) into my playground project (a Rails application I keep to try out new gems/techniques/reproduce bugs).

Something I solved different in my own implementation are the equality operators ==, eql?, and equal?. There’s some great explanation on Object#eql?, which basically says:

  • equal? should return true only if both references are the same (e.g. a.equal?(a) # => true)
  • eql? should return true when both objects have the same “value” (i.e. in our case, when they refer to the same ISBN)
  • == behaves the same like eql?, but for classes like Numeric it performs type conversion (e.g. 1 == 1.0 # => true)

For my Isbn class, I’ve implemented it as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# app/models/isbn.rb
class Isbn
  def initialize(value)
    @value = value.to_s.delete('-')
  end

  def ==(other)
    other = Isbn.new(other) unless other.is_a?(Isbn)
    hash == other.hash
  end

  def eql?(other)
    return false unless other.is_a?(Isbn)

    hash.eql?(other.hash)
  end

  def hash
    value.hash
  end

  private

  attr_reader :value
end

This allows us to e.g. instantiate a Isbn object from a string (a = Isbn.new('978-0465050659')), but also from a number (b = Isbn.new(9_780_465_050_659)). Those two objects will be equal (a == b # => true).

Here's the corresponding spec file with the full range of tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# spec/models/isbn_spec.rb
require 'rails_helper'

describe Isbn do
  describe '#initialize' do
    subject { described_class.new(value) }

    context 'with a string value' do
      let(:value) { '978-0465050659' }

      it { expect(subject) }
    end

    context 'with an integer value' do
      let(:value) { 9_780_465_050_659 }

      it { expect(subject) }
    end
  end

  describe '#==' do
    subject { a == b }

    context 'with matching values and classes' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { described_class.new('978-0465050659') }

      it { is_expected.to eq true }
    end

    context 'with matching values (strings)' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { '978-0465050659' }

      it { is_expected.to eq true }
    end

    context 'with matching values (integers)' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { 9_780_465_050_659 }

      it { is_expected.to eq true }
    end

    context 'with non-matching values' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { '978-0465050658' }

      it { is_expected.to eq false }
    end
  end

  describe '#eql?' do
    subject { a.eql?(b) }

    context 'with matching values and classes' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { described_class.new('978-0465050659') }

      it { is_expected.to eq true }
    end

    context 'with matching values (strings)' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { '978-0465050659' }

      it { is_expected.to eq false }
    end

    context 'with matching values (integers)' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { 9_780_465_050_659 }

      it { is_expected.to eq false }
    end

    context 'with non-matching values' do
      let(:a) { described_class.new('978-0465050659') }
      let(:b) { described_class.new('978-0465050658') }

      it { is_expected.to eq false }
    end
  end

  describe '#valid?' do
    subject { described_class.new(value).valid? }

    context 'with a valid ISBN' do
      let(:value) { '978-0465050659' }

      it { is_expected.to eq true }
    end

    context 'with an valid ISBN' do
      let(:value) { '978-0465050658' }

      it { is_expected.to eq false }
    end
  end
end

The next step is to integrate this approach with ActiveRecord. If you have any tips, leave them in the comments!

This blog post was first published to my email list. If you wanna receive articles like this one first, please subscribe below!