Ruby on RailsアプリケーションのコードをリファクタリングするためのデザインパターンのひとつにValueオブジェクトというものがあります。このパターンをうまく導入することで、モデルが肥大化するFat Modelという問題を防ぐことができます。
この記事ではValueオブジェクトとはなにか、導入する必要性や導入方法について書いていきます。
Ruby on Railsアプリケーションの開発や学習に役立つ本を、「Ruby on Railsのおすすめな本」で紹介しています。あわせてご覧ください。
テクニカルライター。元エンジニア。共著で「現場で使えるRuby on Rails 5」を書きました。プログラミング教室を作るのが目標です。
Valueオブジェクトとは
Valueオブジェクトとは、その名のとおり値を表すオブジェクトです。ここでいう値とはなんでしょうか。この値とはドメイン駆動設計という設計手法に登場する概念です。
ドメイン駆動設計には、エンティティと値オブジェクトというふたつの概念が登場します。
概念 | 概要 | 例 |
---|---|---|
エンティティ | あるオブジェクトが持つ値が同じなら同一だと言えるもの | ユーザー |
値オブジェクト | 同一性を判断できない情報 | 名前、住所 |
値オブジェクトの例
この説明だけだとわかりづらいので、例を見てみます。たとえば、同じ家に住むAさんとBさんがいます。同じ家に住んでいる、つまりふたりの住所は同じですが、住所が同じだからといってAさんとBさんが同一人物というわけではありません。
住所が同じだけだとその人の同一性が判断できないので、住所は値オブジェクトということになります。同じく血液型も、同じO型でも同一人物だと判断できないので値オブジェクトです。
ではマイナンバーはどうでしょうか。マイナンバーは個人を識別するための一意な番号です。つまり同じマイナンバーを持つ人はいません。マイナンバーが同じなら、それは同一人物をさすことになります。
人がマイナンバーを持つとき、人はエンティティである、ということになります。
値オブジェクトとは、住所や血液型など、値が同じでも同一性を判断できない情報のことをいいます。
RailsにおけるValueオブジェクト
RailsにおけるValueオブジェクトというデザインパターンは、ドメイン駆動設計における値オブジェクトをカプセル化する設計手法、ということになります。
なぜValueオブジェクトが必要か
Valueオブジェクトは、クラスを責務で分離し、コードをクリーンに保つために必要になります。コードをクリーンに保つことで重複を排除し、テストが書きやすくなり、またFat Modelの問題を解消することができます。
ソフトウェア設計の基本原則のひとつとして、オープン・クローズドの原則というものがあります。簡単にいうと、次のようになります。
あるクラスを修正するときに、他のクラスに影響が出ないような設計であること
Valueオブジェクトを導入することで、この原則を守ることができます。
例: ユーザーの名前を表す
なぜValueオブジェクトが必要なのか、例をとおしてみてみます。たとえばユーザーと管理者というふたつのクラスがあるとします。ふたつのクラスはそれぞれ名前として姓と名をもちます。また姓と名からフルネームを取得できるようにします。
ユーザーを表すクラスをUserとすると、このコードは次のようになります。
class User def initialize(first_name, last_name) @first_name = first_name @last_name = last_name end def full_name "#{first_name} #{last_name}" end private attr_reader :first_name, :last_name end
これでよさそうに見えますが、問題がひとつあります。管理者を表すAdminクラスにも、Userクラスと同じ #full_name
メソッドを定義する必要が出てくるのです。さらに、たとえばゲストを表すGuestというクラスを追加することになったとき、同じように定義をふやす必要があります。
また、フルネームにミドルネームを追加したくなったらどうなるでしょうか。ユーザー、管理者、ゲストそれぞれの定義を変更する必要が出てきます。「名前」という値の影響範囲が大きくなってしまうのです。これは上で書いたオープン・クローズドの原則に反してしまいます。
この問題を解決するために、「名前」をひとつのクラスに閉じ込めて、各クラスからはこのクラスを使うようにします。
class Name def initialize(first_name, last_name) @first_name = first_name @last_name = last_name end def full_name "#{first_name} #{last_name}" end private attr_reader :first_name, :last_name end class User def full_name name = Name.new(first_name, last_name) name.full_name end end
こうすることで、ミドルネームが追加されたときもNameクラスを修正すればいいことになります。ここでいうNameクラスがValueオブジェクトです。Valueオブジェクトを導入することで、コードの再利用性が上がり、テストも書きやすくなります。
住所や名前といった値をひとつのクラスに切り出して、影響範囲を最小限にするのがValueオブジェクトの役割です。
Valueオブジェクトの使い方
それでは実際にValueオブジェクトの使い方について見てみます。ここでは上で示した「名前」に関するValueオブジェクトをRailsアプリケーションに導入してみます。
RailsのActiveRecord::Baseには #composed_of
というメソッドがあります。これは複数のカラムを擬似的にひとつのカラムとして扱うためのメソッドです。Valueオブジェクトを表すのに適しているので、このメソッドを用いて実装します。
動作環境
この記事の内容は、次の各環境で動作を確認しています。
ライブラリ | バージョン |
---|---|
Ruby | 2.7.2 |
Ruby on Rails | 6.1.0 |
テーブル定義
この記事で示すコードは、次のテーブル定義をもとに動作します。コードはユーザーや管理者、ゲストなどを想定していますが、ここではユーザーのテーブル定義のみを示します。
テーブル | カラム | データ型 | NOT NULL |
---|---|---|---|
users | id | int | ◯ |
first_name | varchar | ||
last_name | varchar | ||
middle_name | varchar | ||
created_at | datetime(6) | ◯ | |
updated_at | datetime(6) | ◯ |
実装例
それでは実装例を見てみます。ユーザーや管理者がいて、「名前」という値オブジェクトを持っているとします。このとき、ValueオブジェクトであるNameクラスは次のようになります。
class Name attr_reader :first_name, :last_name def initialize(first_name, last_name) @first_name = first_name @last_name = last_name end def full_name "#{first_name} #{last_name}" end end
このNameクラスを、ユーザーを表すUserクラスでValueオブジェクトとして持たせるためには、上で説明した #composed_of
メソッドを利用します。
class User < ApplicationRecord composed_of :name, mapping: [%w(first_name first_name), %w(last_name last_name)] end
#composed_of
の第一引数には属性名をシンボルで渡します。この属性名からクラス名が推測できる、つまり :name
ならNameクラスである場合は :class_name
オプションを省略できますが、推測できない場合はこのオプションを用いてクラスを指定します。
また、 :mapping
オプションでUserクラスとNameクラスの属性のマッピングを行います。Userクラスにおける first_name
がNameクラスにおける first_name
に対応します、という形です。
以上でValueオブジェクトを利用できるようになりました。これによりNameクラスにミドルネームを追加するといった変更を行っても、Userなど各クラスに影響を及ぼすことがなくなりました。つまり、オープン・クローズドの原則が守られるようになりました。
参考: Valueオブジェクトを用いた検索
上記のように #composed_of
でValueオブジェクトを設定すると、 Valueオブジェクトを用いた検索も行えるようになります。たとえば、上記のNameクラスによる検索は次のようになります。
users = User.where(name: Name.new('Taro', 'Yamada'))
Valueオブジェクトのユースケース
Valueオブジェクトは、ある値が抽象化できる概念であり、かつ複数のクラスから属性として参照されるケースで利用すると良いでしょう。たとえば、次のようなケースです。
- 名前における姓名やミドルネーム、住所における国や県・市区町村など、複数の値を組み合わせてひとつの値を算出するもの
- 日付や通過、気温、あるいは商品のレビューにおける星の数など、値の表現や比較を行うもの
いつValueオブジェクトを導入するか
Valueオブジェクトは、その値オブジェクトを複数のクラスから利用することになったら導入するとよいでしょう。たとえば、「名前」を扱うクラスがUserしかないときにNameクラスとして切り出すのは早すぎるかもしれません。
名前だけならまだいいですが、それ以外にも値オブジェクトとして切り出せるものはたくさんあります。このすべてをその都度Valueオブジェクトとして扱うと、かえってコードが煩雑になってしまいます。
この目安として、複数のクラスから利用されるようになったらValueオブジェクトとして切り出す、という指針があるとよさそうです。
Valueオブジェクトは、複数のクラスから参照されるようになったときにはじめて導入することで、コードが煩雑になることを防げます。
Valueオブジェクトの設計上のルール
以上をもとに、Valueオブジェクトを設計するときのルールをまとめてみます。
- ファイルは
app/values
下に配置する - ファイル名はValueオブジェクトの名前にする。たとえば
name.rb
- 値オブジェクトをカプセル化する目的でつくる
- クラス名はなんの値オブジェクトかがわかるようにする。たとえばNameやAddressなど。またNameValueのような接尾辞はつけない
- 必要最低限のインタフェースのみを公開する。たとえば
full_name
など - 公開したインタフェースに対するユニットテストを書く
まとめ
Valueオブジェクトは、コードをクリーンに保つためのすぐれたデザインパターンのひとつです。複数のクラスから利用される値オブジェクトが見つかったとき、導入することでFat Modelなどの問題を防ぐことができます。