Railsで重要なパターンpart 1: Service Object(翻訳)
Service Object(単にServiceと呼ばれることもあります)は、肥大化したActiveRecordモデルを分割し↓、コントローラをスリムかつ読みやすくするうえで非常に有用な、Ruby on Rails開発における一種の聖杯とも呼ぶべきパターンです。
どんなときにService Objectを使うか
このパターンは非常にシンプルかつ強力なので、つい使いすぎてしまう可能性があります。特に、多数のモデルに対するコールバックやアクセスを含み、多くの手順で構成される複雑なアクションを定義する場所が必要で、かつ他に適切な置き場所がない場合に有用です。Service Objectは、モデルで外部クラスとやりとりするコールバックによって生じる問題(関連記事)を軽減するのにも用いられます。
Service Objectパターンを最大限に活用するための注意点
1. 命名規則を1つに定める
プログラミングで難易度が高いのは、適切かつ意味のわかる名前を付けることです。Service Objectの場合、UserCreator
やTwitterAuthenticator
やCodeObfuscator
などのように「〜or」で終わる名前を付ける方法が広く採用されています。この命名規則に従おうとすると、OrderCompleter
のように英語的に少し無理が生じることがあります。
そして私は、CreateUser
やAuthenticateUsingTwitter
やObfuscateCode
やCompleteOrder
のように、コマンドやアクションを先に書く命名方法にすると少しわかりやすくなることに気づきました。この方がService Objectの責務が明確になります。
どの方法を選ぶにせよ、一度命名規則を決めたらそこから外れないようにすることが重要です。
2. Service Objectを直接インスタンス化しないこと
Service Objectをインスタンス化しても、単にcall
メソッドを実行する以外にあまり使いみちがありません。call
メソッドを実行するのであれば、次のように抽象化してService Objectの呼び出しをより簡潔にすることを検討しましょう。
module Service
extend ActiveSupport::Concern
class_methods do
def call(*args)
new(*args).call
end
end
end
このモジュールをinclude
すれば、UserCreator.new(params).call
やUserCreator.new.call(params)
をUserCreator.call(params)
のように簡潔に記述でき、読みやすさも向上します。このService Objectはインスタンス化も可能なので、内部のステートを取り出す必要が生じた場合にも有用です。
3. Service Objectの呼び出し方法を1つに定める
個人的にはcall
メソッドを使うのが好みですが、他の方法を使わない特別な理由があるわけではなく、perform
やrun
やexecute
も候補として優れています。重要なのは呼び出し方法を常に統一することです。というのも、クラスの責務は既にクラス名に明示されており、これ以上明確にする必要はないからです。
呼び出し方法を統一しておけば、新しいService Objectを実装するたびに名前を考える面倒がなくなりますし、他のプログラマーが実装の詳細をチェックしなくてもService Objectの使い方をすぐに理解できるという効用もあります。
4. Service Objectの責務を1つに絞り込む
これは、Service Objectの呼び出し方法を1つに統一しておけば、ある程度実現できますが、それだけではService Objectに複数の責務が混入する可能性が残ります。Service Objectにさまざまなアクションをまとめることも一応可能ですが、アクションのセットは1種類に限定するべきです。
Service Objectでありがちなアンチパターンとしては、たとえばManageUser
というserviceがユーザーの作成と削除の両方を引き受けてしまうというものがあります。そもそもmanageという言葉だけでは責務があいまいになってしまい、オブジェクトがどんなアクションを実行すべきかを制御する方法も明確になりません。代わりにDeleteUser
とCreateUser
という具体的な2つのServiceに分けて導入すれば、コードも読みやすくなり、より自然になります。
5. Service Objectのコンストラクタをシンプルに保つ
一般に、実装するほとんどのクラスではコンストラクタをシンプルにしておくのがよい考えです。Serviceの主な呼び出し方法はクラスメソッドを呼び出すことですが、コンストラクタの責務を「引数をServiceのインスタンス変数に保存すること」に限定する方がずっとメリットが多くなります。
class DeleteUser
def initialize(user_id:)
@user = User.find(user_id)
end
def call
#…
end
end
上と下を見比べてみましょう。
class DeleteUser
def initialize(user_id:)
@user_id = user_id
end
def call
#…
end
private
attr_reader :user_id
def user
@user ||= User.find(user_id)
end
end
下のようにすれば、テストのときにコンストラクタではなくcall
メソッドに集中できるようになりますし、コンストラクタに何を置いてもよく、何を置いてはいけないかという線引きも明確になります。開発者が日々決定しなければならないことはたくさんあるので、こういう点を標準化して決めごとをひとつ減らしましょう。
6. call
メソッドの引数をシンプルにする
Service Objectに2つ以上の引数を渡すのであれば、引数をわかりやすくするためにキーワード引数の導入を検討するとよいでしょう。Service Objectの引数が1つの場合であっても、キーワード引数にしておくことで読みやすさが向上するでしょう。
UpdateUser.call(params[:user], false)
上と下を見比べてみましょう。
UpdateUser.call(attributes: params[:user], send_notification: false)
7. 結果はステートリーダー経由で返す
Service Objectから何らかの情報を取り出さなければならないような状況はめったにありませんが、その必要が生じた場合のアプローチはいくつか考えられます。
Service Objectはcall
メソッドの結果は、たとえば次のように返すことが可能です。実行が成功した場合はtrue
、失敗した場合はfalse
を返す、という具合です。
しかし、call
メソッドがService Object自身を返すようにすれば、もっと柔軟になります。この方法にすれば、Service Objectインスタンスのステートを読み出せるようになります。
update_user = UpdateUser.call(attributes: params[:user])
unless update_user.success?
puts update_user.errors.inspect
end
この方法なら、例外のraiseなど、めったに起きない事象を扱うような場合にも効果的にエッジケースとやりとりできます。
begin
UpdateUser.call(attributes: params[:user])
rescue UpdateUser::UserDoesNotExistException
puts "そのユーザーは存在しません"
end
8. call
メソッドの可読性を下げないよう注意する
call
メソッドはService Objectの中心となるメソッドです。Service Objectでは、call
メソッドをできるだけ読みやすく保つことをおすすめします。call
メソッドには関連する手順だけを記述し、それ以外のロジックは最小限に抑えるようにします。and
やor
などを使って、特定の手順のフローを制御してもよいでしょう。
class DeleteUser
#…
def call
delete_user_comments
delete_user and
send_user_deletion_notification
end
private
#…
end
9. call
メソッドをトランザクションでラップすることを検討する
Service Objectが自らの責務を果たすために複数の手順が必要になった場合、手順をトランザクションでラップするとよいでしょう。こうしておけば、その手順に失敗したときでも常にロールバックできます。
10. Service Objectが増えたら名前空間でグループ化する
Service Objectは遅かれ早かれ何十個にも増えるでしょう。コードを上手に整理するには、名前空間で共通のService Objectをグループ化することをおすすめします。Service Objectをグループ化する名前空間は、「外部サービス」や「高レベルの機能」など考えられるどんな基準で決めてもかまいません。ただし、Service Objectの命名規則や配置を読みやすく素直なものにするのが名前空間の主要な目的であることをお忘れなく。規則を1つに絞り込んでおけば、適切な配置は自然に定まります。不要な選択肢を増やさないようにするのがコツです。
まとめ
Service Objectは、テストを書きやすくするのに役立つシンプルかつ強力なパターンであり、広く応用が利きます。Service Objectは実装が容易ですが、その分実装がばらつかないように管理する必要があります。Service Objectの適切な命名規則を定めることで、呼び出しや結果の受け取り方法を統一し、Service Objectクラスの内部状態をシンプルかつ読みやすく保てば、コードベースでこのパターンから多くのメリットを得られるようになります。
Service Objectパターンで簡単な抽象化が必要になったら、BusinessProcess gemや類似のユースケースを検討しましょう。rails-patterns gemはさらに薄いレイヤを提供しています。
次回のpart 2は「Query Object」です。
概要
原著者の許諾を得て翻訳・公開いたします。
パターンの種別は原則として英語表記にしました。