11 複雑なドメインを表現する
雑感
この章は Rails のデザインパターンである値オブジェクトとサービスオブジェクトに関する内容でした。複数の属性やオブジェクトが絡み合う複雑なロジックをまとめる方法、そしてどれを導入する際の基準やルールについてです。しかし、次章で取り上げる concern との導入ケースの差異など、本書のみの記述では個人的に曖昧感じる部分が多かったので、この記事では本書の内容を超えて別のソース記事からの情報も交えてまとめています。(最終段参照)
特にサービスオブジェクトに関しては Rails 開発者の中でもネガティブな意見が多く、実際に導入する際にはもう一度深掘りして再検討する必要がある印象です。
ドメインとは
ドメインとはアプリケーションが対象とする問題領域のことを指す。
・ ドメインモデル
ドメインを分析して構成概念を抽出(モデリング)し、その結果得られた概念のこと。その概念に関連する属性と振る舞いを持ったオブジェクトとして定義される。
・ ビジネスロジック(ドメインロジック)
上述したドメインモデルの振る舞いのこと。
・ アクティブレコード
ドメインモデルとその状態を保存した DB のレコードを対応づけ、データの取得・保存処理とビジネスロジックを合わせてカプセル化するアーキテクチャパターン。
値オブジェクト
アプリケーションを構成するオブジェクトは、その同一性の捉え方によって以下の 2 種類に大別される。
-
エンティティ
同一性を識別する情報(識別子)によって一意に識別されるオブジェクト。
例えば、同姓同名かつ生年月日が同じユーザーオブジェクトは id という識別子によって別オブジェクトとして識別される -
値オブジェクト(Value Object)
姓・名・生年月日など値が同じであれば、同じものとして扱うことができるため、値により一意に判別することができないオブジェクト
値オブジェクトを導入することで、複数モデルにおいて共通で存在する属性(name[名前]や phone_number[電話番号]など)に同じビジネスロジックを定義できる。
値オブジェクトの導入
User, Admin など複数のクラスにわたって以下のような属性とメソッドが設定されている場合、
- 整形用の
name
メソッドによって各モデルの記述が増えて fat になってしまう。別の整形方法が必要になった場合はさらにモデルの記述が増え、同じ属性を持つ全てのファイルに同様の編集が必要 - 本来、意味を持つのは
name
でありname
を一つの意味のある単位として扱いたいが、name
メソッドによって返却されるのは単なる文字列 -
name
メソッドで取得しても、そこからfirst_name
などの一部を取り出すのは容易ではない
# Adminクラスなどにも同様の属性とメソッドが定義されている場合
class User
attr_accessor :first_name, :middle_name, :last_name
def name
first_name + middle_name + last_name
end
end
上記の問題を解決するために、値オブジェクトを導入し「名前」に関するロジックをName
というクラスに切り出すことでname
メソッドでName
オブジェクトが返却されるようにする。これにより「名前」に関するロジックが元のモデルから切り出され、name
という一つのオブジェクトとして扱える。first_name
などの一部を切り出すことも容易となる。
class User
attr_accessor :first_name, :middle_name, :last_name
def name
Name.new(first_name, middle_name, last_name)
end
end
class Name
attr_reader :first_name, :middle_name, :last_name
def initialize(first_name, middle_name, last_name)
@first_name, @middle_name, @last_name = first_name, middle_name, last_name
end
# このクラスに名前に関するバリデーションや整形ロジックを記述する
end
Rails で composed_of によって値オブジェクトを導入する
composed_of
は Rails でモデルの属性を値オブジェクトとして表現するためのメソッド。
上記の「名前」に関する値オブジェクトは Rails way で以下のように実装できる。以下のように設定することでuser.first_name
がuser.name.first
へマッピングされる。
class User
attr_accessor :first_name, :middle_name, :last_name
# 第一引数は値オブジェクトのクラス名
# 第二引数以降はモデルと値オブジェクトの対応関係をマッピング
# [モデルの属性, 値オブジェクトの属性]
composed_of :name, mapping: [%w(first_name first), %w(middle_name middle), %w(last_name last)]
end
class Name
attr_reader :first, :middle, :last
def initialize(first, middle, last)
@first, @middle, @last = first, middle, last
end
end
以下のようにallow_nil
オプションを true に設定することで属性を nil に指定しても値オブジェクトが作成できる
composed_of :name,
mapping: [%w(first_name first), %w(middle_name middle), %w(last_name last)]
+ allow_nill: true # デフォルトはfalse
上記のように composed_of
で値オブジェクトを設定すると、 値オブジェクトを用いた検索も行えるようになる。例えば、上記の例の場合、
users = User.where(name: Name.new('Monkey', 'D', 'Luffy'))
いつ導入すべきか
- その値オブジェクトを複数のクラスから利用することになった場合(User, Admin の「名前」など)
- 複数の属性値を「一つの値」として扱いたい場合(
first_name
,middle_name
,last_name
を一つのname
として扱いたいなど)
値オブジェクト設計上のルール
- ファイルは app/values 下に配置する
- ファイル名は Value オブジェクトの名前にする。たとえば name.rb
- クラス名はなんの値オブジェクトかがわかるようにする。たとえば Name や Address など。また NameValue のような接尾辞はつけない
- 必要最低限のインタフェースのみを公開する。たとえば full_name など
サービスオブジェクト
例えば下記の口座残高を表す BankAccount モデルの二つのインスタンス間における送金処理ロジックを実装する場合、複数のオブジェクトの状態を更新するためモデルにインスタンスメソッドとして実装するのは不自然である。そのためクラスメソッドとして実装するのが自然であるが、このような複数のオブジェクトを操作するロジックを実装するとモデルが fat になりやすいためこのような場合はサービスオブジェクトを導入するとよい。
# 口座残高を表すモデル
class BankAccount < ApplicationRecord
# balance(残高)
# moneyオブジェクトをやり取りする
composed_of :balance, class_name: "Money", mapping: [%w[balance amount], %w[currency currency]]
def deposit(money)
with_lock { update!(balance: balance + money) }
end
def withdraw(money)
with_lock do
raise "Withdrawal amount must not be greater than balance" if money > balance
update!(balance: balance - money)
end
end
end
# fromが送金元の口座残高オブジェクト
# toが送信先の口座残高オブジェクト
from.transaction do
from.withdraw(money)
to.deposit(money)
end
サービスオブジェクトの導入
上記の送金処理ロジックはサービスオブジェクトを導入すると以下のように実装できる。
# 送金処理ロジックを責務とするレコードを持たないクラスを定義する
class TransferMoneyBetweenBankAccountsService
# インスタンス化を制限し、クラスメソッド .call 経由でのみ実行可能としている
private_class_method :new
# インスタンス生成(new)とメソッド呼び出し(call)がクラス名.callで一度で呼び出すクラスメソッド
# **kwargsは渡された全ての任意のキーワード引数をハッシュとして受け取る
def self.call(**kwargs)
new.call(**kwargs)
end
def call(from:, to:, money:)
from.transaction do
from.withdraw(money)
to.deposit(money)
end
end
end
# 使用例
TransferMoneyBetweenBankAccountsService.call(
from: account1,
to: account2,
money: 10000
)
サービスオブジェクト設計上のルール
- ファイルは app/services 下に配置する
- ファイル名は「#(モデル名)Service」のような何を目的とした実装なのか曖昧なものとしてはならない。ある一つのビジネスロジックを指す名前にする(TransferMoneyBetweenBankAccountsService など)
- 命名したロジックを実行するためのクラスメソッドを一つのみ公開する(上記の例では call メソッドのみ)
- モデルに実装すべきビジネスロジックを実装しない
- サービスクラスのインスタンスは状態をもたせない。そのためにサービスクラス外からはインスタンスを生成できないようにする
例えば、上記の送信処理ロジックの記録を残したいとなった場合、送信処理イベントそのものを表現するモデルを導入することでサービスオブジェクトを利用しなくてもより自然な形で実装することができる。
イベントの記録など、サービスオブジェクトを導入する前にそのイベントの対応についての見落としがないかを確認する必要がある。
参考