3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【ドメイン駆動設計】ValueObjectサンプルコード:プリミティブなオブジェクトみたいに振る舞う

Posted at

はじめに

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クラス

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?