🐰

値オブジェクト 所感

に公開

はじめに

ちいかわ、ハチワレ、誕生日おめでとう。

現在、私はドメイン駆動設計(DDD)を取り入れているプロジェクトに携わっています。
DDDに触れるのは今回が初めてで、集約やエンティティ、値オブジェクトといった“戦術的設計”の要素も、これまで扱ったことがありませんでした。

実装を始めてから約3ヶ月。試行錯誤しながらも、少しずつ手応えを感じられるようになってきました。
その中でも特に「これは良いな」と実感したのが、値オブジェクトです。

この記事では、値オブジェクトを使っていて感じたことや気づきを、現時点でのメモとしてまとめておこうと思います。
同じようにDDDを学びはじめた方の参考になればうれしいです。

生成ルートの制限

これまでは生成ルートなど考えず、脳死でコンストラクタをpublicでインスタンスを生成していました。
そんな自分が、生成経路について頭を使うことに目覚めたこと自体が大きな成長だと感じています。

サンプルコード

  def self.build_from(monthly_payment_day:, subscription_month:)
    day = [monthly_payment_day.to_i, subscription_month.last_date.day].min
    self.new(day)
  end

  private_class_method :new

  def initialize(value)
    @value = value
    verify!
  end

わざわざこれ値オブジェクトにする?問題

結構迷う場面があるんですが、

  • それ単体で表現したくなる
  • 名前を与えたくなる存在か
  • 自分がこれをちゃんと概念として捉えないと混乱してしまう

みたいなところですかね。
これ以外にも開発初期段階だからまだ過剰だろ、っていうケースバイケーステクニカルサボリもあったりします。
ただ、今後経験を積んでいく中で、また違う見方ができるようになるのかもしれません。

サンプルコード

継続課金決済月.rb
  def initialize(year:, month:)
    # TODO: yearとmonthも値オブジェクトにする?置き場所どうしよう
    @year = year
    @month = month
    verify!
  end

スラスラ読める

エンティティのドメインロジックを書くときに、「〜が」「〜を使って」「〜する」といった自然な表現ができるのは、値オブジェクトにロジックが凝集されているおかげです。
コードがとても直感的に読めると感じました。

def ==(other)バカにしてました、シャザーイ

「いちいち定義するのめんどくさくない?」「必要?」って思ってました。しゃらくさでした。
実装中に初めて、値オブジェクトを比較する場面に出くわした時に、==を定義して値オブジェクトとプリミティブ値をスラスラっと比較できた時、「だからこの実装が必要なんだ〜」と心で理解しました。

僕の想像力や理解力が低いのもありますが、「とりあえず使ってみる」「とりあえずやってみる」は大事ですね。

サンプルコード

  def ==(other)
    case other
    when self.class
      @date == other.to_date
    when Date
      @date == other
    else
      false
    end
  end

値オブジェクトは触媒

最近読んだ鈴木宏昭さんの書籍『私たちはどう学んでいるのか』には、「学びとは、積み上げられた構造が次の認知を“創発”させるプロセスである」(意訳)という話が出てきます。
この考え方を借りるなら、精巧に設計された値オブジェクトは、開発者にとって概念の抽出や構造化を“創発”させる触媒のような存在だと感じました。

たとえば、概念をちゃんと分解しないまま実装を進めていると、「なんか〜…なんかコレ〜…この値オブジェクトの責務にしたくないかもぉ〜〜〜!!」みたいな、言葉にならない違和感や嫌悪感がふと湧いてくることがあります。
でもそれって、実はコードが「概念が未分解だよ」と教えてくれているサインなんですよね。
(※概念をどこまで分解するかの正解はない)

自分の書いたコードが、未来の自分の思考に変化を促す、そんな体験は、今まで設計に疎かった自分にとって初めてであり、ちょっと感動しました。

サンプルコード(withざっくりコンテキスト)

継続課金:サブスクリプションです。月次で決められた日付で決済を行います。
月次決済日:継続課金の決済を毎月何日に行うかの日付。1~31の数値(Integer)です。
継続課金決済月:年と月を持つ。月末日を返すメソッドを持つ。

月次決済日.rb
  # NOTE: 月次決済日よりも、その年月の月末日の値の方が小さい場合は、その年月の月末日を返す
  #       例:月次決済日が31日の場合、2月は28日、3月は31日を返す
  def その月の日にちに補正して返すよ(継続課金決済月)
    月末day = 継続課金決済月.月末日.day
    [@value, 月末day].min
  end

  # NOTE: 月次決済日との比較を行う
  def その日がその月の継続課金の決済を行う日ですか?(継続課金決済月:, 対象day:)
    その月の日にちに補正して返すよ(継続課金決済月) == 対象day
  end

「その月の日にちに補正して返すよ」が息苦しくない?wow wow
補正ってファクトリーメソッドの文脈でよく聞くやつじゃね?wow wow
なんか〜…なんかコレ〜…この値オブジェクトの責務にしたくないかもぉ〜〜〜!!(ゲキアツ)
よし、この概念の値オブジェクト作ろう(創発…ってコト!?キュインキュキュキュキュキュイン)

月適用決済日(激ヤバ命名).rb
  def self.build_from(月次決済日:, 継続課金決済月:)
    # NOTE: 月次決済日よりも、その年月の月末日の値の方が小さい場合は、その年月の月末日を返す
    #       例:月次決済日が31日の場合、2月は28日、3月は31日を返す
    day = [月次決済日.to_i, 継続課金決済月.月末日.day].min
    self.new(day)
  end

  private_class_method :new

  def initialize(value)
    @value = value
  end

  def ==(other)
    case other
    when self.class
      @value == other.to_i
    when Integer
      @value == other
    else
      false
    end
  end

すっきりんこ(「背すじをピン!と〜鹿高競技ダンス部へようこそ」より)

追記:後々考えたら、この値は決済のドメイン領域での意味を持たない値であり、汎用的な日付操作用のクラスに移動しました。でもこれによってドメイン層での責務がより明確になりました。チャンチャン

以上

読んでくださり、ありがとうございました。
プロジェクトについても、いつか改めてこの記事と共に振り返りたいと思います。
それではみなさん、1度きりの2025年5月を楽しんで。

wwwave's Techblog

Discussion