バリューオブジェクト(ValueObject)パターン(PofEAA)

2022/8/10 追記。昨今、バリューオブジェクトに関してtwitterで色々話題になっているため、バリューオブジェクトについての追記を書いた。

値をラップするクラスを作るパターン。

DDDのエンティティ(Entity)との比較で、状態を持たない(副作用のある関数を持たない)とか、識別子ではなく値によって等価であるか比較すると言ったことが言われる。

ただ、こういった性質の話題は作るべきバリューオブジェクトを見つけるのには役に立たないし、コードの性質という意味では、私は「部分適用した関数を束ねたモノ」とみるのがシンプルだと考えている。例えば複素数のバリューオブジェクトを考えてみる。

class ComplexValueObject
  attr_reader :real_part, :imaginary_part
  def initialize(real_part, imaginary_part)
    @real_part = real_part
    @imaginary_part = imaginary_part
  end

  def add(another)
    self.class.new(@real_part + another.real_part,
                   @imaginary_part + another.imaginary_part)
  end

  def sub(another)
    self.class.new(@real_part - another.real_part,
                   @imaginary_part - another.imaginary_part)
  end
end

このaddメソッド、subメソッドはインスタンスメソッドなのでインスタンス変数@real_partなどにアクセスできている。

メソッドではなく、関数として独立させると以下のようになるだろう。

class ComplexData
  attr_reader :real_part, :imaginary_part
  def initialize(real_part, imaginary_part)
    @real_part = real_part
    @imaginary_part = imaginary_part
  end
end

# oneもanotherもComplexDataのインスタンス
def add(one, another)
  # 省略
end

def sub(one, another)
  # 省略
end

# あるいは
def add(real_part, imaginary_part, another)
  # 省略
end

def sub(real_part, imaginary_part, another)
  # 省略
end

どのような関数にしたとしても、共通する引数を渡す関数のセットになる。なので、引数の共通する部分をインスタンス変数とし、それら関数のセットをインスタンスメソッドとして抱えるのがバリューオブジェクトだと考えることができる。

関数をまとめたモノであるため、当然状態は持たない。値による等価比較は必須ではないし、識別子についてはナチュラルキーが意味を持つ場合にややこしい事になるので忘れた方が良い。

バリューオブジェクトの実装例として、コンストラクタで値をチェックし不正値だったら例外を投げるコードをあげているケースがある。

# マイナスの数値はありえないバリューオブジェクト
class SomeNumberValueObject
  def initialize(number)
    raise '不正な値!' if number < 0
  end
end

これは、バリューオブジェクトと契約による設計を組み合わせているだけであり、必ずしもこのように実装しなければならないわけではない。少なくともエンタープライズアプリケーションアーキテクチャパターンエリック・エヴァンスのドメイン駆動設計のどちらとも、そのようにするべきと書かれていないし、そもそも値をチェックすることについて言及がない。

SQLのNULLや浮動小数計算におけるNaNのように、無効であることを伝播させる方法もある。

# マイナスの数値はありえないバリューオブジェクト
class SomeNumberValueObject
  # マイナスの値かNaNだったら不正
  def initialize(number)
    @invalid = number < 0 || number.to_f.nan?
    @number = number
  end

  # 不正値だったらNaNを返す
  def value
    return Float::NAN if @invalid

    @number
  end

  # NaN + 1 => NaN
  # 1 + NaN => NaN
  def add(another)
    self.class.new(value + another.value)
  end
end

また、ドメインオブジェクトとしてのバリューオブジェクトと言語を補強するためのバリューオブジェクトは区別した方が良い。

特定の色の製造が制限されている色混合クラスならドメインオブジェクトとしてのバリューオブジェクトになるはずで、単にRGBカラーを表現するクラスなら言語を補強するバリューオブジェクトだろう。前者は関連するドメインオブジェクトと同じモジュールに入れるべきであるし、後者は共有モジュールに格納しておくのがよいだろう。また、色の混合計算処理を前者のクラスから後者のクラスに委譲する方法も可能である。

マネー(Money)パターンは金額計算に特化したバリューオブジェクトであり、上記の色混合クラスと同様に、ドメインオブジェクトとしてのマネークラスなのか、言語を補強するためのマネークラスなのかは区別した方が良いだろう。

バリューオブジェクトを見つける方法については、要求分析プラクティスであるデータディクショナリを用いるのが良いと私は考えている。要求分析駆動設計のデータディクショナリで言及しているのでそちらを参照してもらいたい。