はじめに
DDD(ドメイン駆動開発)に登場するValueObjectをRubyで実装しようとして
コードを書き進めると、プリミティブっぽく振る舞うようにするにはどうすれば良いんだっけ?
と悩むシーンがあったので
どんなメソッドを実装しておくと便利になるのか、サンプルにまとめてみました。
(ValueObject自体の説明はここでは割愛させていただきます)
ValueObjectの特徴
ValueObjectを実現するには、次の特徴を実装しなければなりません。
- 値は不変(後から変更できない)
- 値が同じなら同一オブジェクトと判断
(単一の値よりも複数の値をひとかたまり(合成物)として扱う方がより効果的)
他にもIntegerやStringなどのプリミティブなオブジェクトと同じような特徴を備えていると便利です。
- 比較演算子で比較できる
- 配列に入れてソートできる
- ハッシュのキーにできる
- 範囲(Range)に使える
サンプル:年月をValueObjectで表現してみる
年月をValueObjectとして表現します。
- 年と月を渡すとオブジェクトが作られる
- 月初と月末の日付をもっている
- ある日付を渡すとその年月に含まれているか判断することができる
サンプルコード(ruby)
require 'date'
class YearMonthValue
include Comparable
def initialize(year, month)
min = Date.new(year, month, 1)
max = Date.new(year, month, (min.next_month - 1).day)
@range = Range.new(min, max)
end
def value
@range.min
end
def cover?(date)
@range.cover?(date)
end
def to_s
value.strftime('%Y/%m')
end
def <=>(other)
value <=> other.value
end
def eql?(other)
value.eql?(other.value)
end
def hash
value.hash
end
def succ
date = @range.min.next_month
self.class.new(date.year, date.month)
end
end
サンプルコード各メソッド説明
initializeメソッド
ValueObjectは値が不変になるようにしたいので、
initialize時に受け取った値をインスタンス変数に保持するようにします。
インスタンス変数を外部から変更できるようなaccessorや、
外部からインスタンス変数が変更されるようなメソッドは作らないようにします。
def initialize(year, month)
min = Date.new(year, month, 1)
max = Date.new(year, month, (min.next_month - 1).day)
@range = Range.new(min, max)
end
valueメソッド
このオブジェクトが持つ値を返すようにします。後で出てくる比較などに使います。
このサンプルではRangeオブジェクトのminを返していますが、to_sや他の値を返すやり方もあります。
def value
@range.min
end
cover?(date)メソッド
この年月クラスは、ある日付を渡すとその年月に含まれているか判断できるようにします。
これはValueObjectの特徴を実現するためではなく、年月クラスとして便利に使うためのものです。
def cover?(date)
@range.cover?(date)
end
ym = YearMonthValue.new(2022, 7)
puts ym.cover?(Date.new(2022, 7, 15))
true
puts ym.cover?(Date.new(2022, 8, 15))
false
to_sメソッド
このオブジェクトが持つ値の文字列表現を返します。
def to_s
value.strftime('%Y/%m')
end
puts YearMonthValue.new(2022, 7).to_s
2022/07
puts YearMonthValue.new(2022, 9).to_s
2022/09
<=>(other)メソッド
クラスは”Comparable”をIncludeしています。
そして、この<=>メソッドを実装することでValueObject同士を比較することができるようなります。
==、<、>、<=、=>、!= などの比較演算子は、true または false を返しますが
<=>演算子は、true、falseでなく
等しければ0、左辺が大きければ+1、右辺が大きければ-1 をそれぞれ返します。
このメソッドの結果を使ってComparableの処理が行われます。
メソッドを実装していないと。。。
puts YearMonthValue.new(2022, 7) == YearMonthValue.new(2022, 7)
false
同値だけれどオブジェクトが別と判定されて false となる
メソッドを実装する
(class定義の下で、include Comparableを忘れずに)
def <=>(other)
value <=> other.value
end
puts YearMonthValue.new(2022, 7) == YearMonthValue.new(2022, 7)
true
オブジェクトが同一かどうかの比較(デフォルトの比較)から、
値が同じかどうかの比較に変わったので true と判定されるようになる。
そして
puts YearMonthValue.new(2022, 7) == YearMonthValue.new(2022, 7)
true
puts YearMonthValue.new(2022, 7) != YearMonthValue.new(2022, 8)
true
puts YearMonthValue.new(2022, 8) > YearMonthValue.new(2022, 7)
true
puts YearMonthValue.new(2022, 7) < YearMonthValue.new(2022, 8)
true
Comparableとして機能するようになる。
さらに
array = []
array.push(YearMonthValue.new(2022, 5))
array.push(YearMonthValue.new(2022, 7))
array.push(YearMonthValue.new(2022, 6))
array.push(YearMonthValue.new(2022, 4))
puts array.sort
2022/04
2022/05
2022/06
2022/07
Comparableが持つsortも使えるようになる。
このサンプルでは比較する際に、valueメソッドがあれば比較が行えるようになっていますが
必要に応じて同じクラスかどうかのチェックを行なっても良いでしょう。
eql?(other) と hash メソッド
ハッシュのキーとして使えるようにするために必要です。
hash = {}
hash[YearMonthValue.new(2022, 7)] = 123
hash[YearMonthValue.new(2022, 8)] = 456
puts hash[YearMonthValue.new(2022, 7)]
puts hash[YearMonthValue.new(2022, 8)]
(何も表示されない)
ハッシュに値を代入する際のキーとして指定した「YearMonthValue.new(2022, 7)」と
ハッシュから値を取り出す際にキーとして指定した「YearMonthValue.new(2022, 7)」が
同一ではないと判断されてハッシュから値を取り出すことが出来ていない。
「YearMonthValue.new(2022, 8)」も同様。
def eql?(other)
value.eql?(other.value)
end
def hash
value.hash
end
hash = {}
hash[YearMonthValue.new(2022, 7)] = 123
hash[YearMonthValue.new(2022, 8)] = 456
puts hash[YearMonthValue.new(2022, 7)]
puts hash[YearMonthValue.new(2022, 8)]
123
456
ハッシュに値を代入する際のキーとして指定した「YearMonthValue.new(2022, 7)」と
ハッシュから値を取り出す際にキーとして指定した「YearMonthValue.new(2022, 7)」が
同一と判断されてハッシュから値を取り出すことが出来た。
「YearMonthValue.new(2022, 8)」も同様。
succメソッド
自分が持つ値を1つ進めた新しいオブジェクトを返すメソッドです。
このメソッドを実装することでRangeオブジェクトの開始、終了に使うことができるようになります。
自分自身の値を変更せずに新しいオブジェクトを返すのがミソです。
range = Range.new(YearMonthValue.new(2022, 1), YearMonthValue.new(2022, 5))
range.each do |ym|
puts ym.to_s
end
(エラーが発生する)
def succ
date = @range.min.next_month
self.class.new(date.year, date.month)
end
def <=>(other)
value <=> other.value
end
range = Range.new(YearMonthValue.new(2022, 1), YearMonthValue.new(2022, 5))
range.each do |ym|
puts ym.to_s
end
2022/01
2022/02
2022/03
2022/04
2022/05
最後に
DDDの概念は難しく、特にRuby on Railsに導入するのはハードルが高いと感じています。
しかし、ValueObjectは比較的シンプルな考え方なので、
他のDDDの概念とは切り離して単独で導入することもできると考えています。
その場合は、DDDというよりもデザインパターンのImmutableオブジェクトになるのかもしれませんね。
上手く使ってコードの可読性と保守性を上げていきましょう。
参考
ValueObjectという考え方
Rubyリファレンスマニュアル Objectクラス
Rubyリファレンスマニュアル Hashクラス
Rubyリファレンスマニュアル Rangeクラス