Railsで重要なパターンpart 1: Service Object(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

パターンの種別は原則として英語表記にしました。

Railsで重要なパターンpart 1: Service Object(翻訳)

Service Object(単にServiceとも呼ばれます)は、肥大化したActiveRecordモデルを分割(訳注: TechRacho翻訳記事にリンクしました↓)し、コントローラをスリムかつ読みやすくするうえで非常に有用な、Ruby on Rails開発における一種の聖杯とも呼ぶべきパターンです。

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

どんなときにService Objectを使うか

このパターンは非常にシンプルかつ強力なので、つい使いすぎてしまうほどです。特に、多数のモデルに対するコールバックやアクセスを含み、多くの手順で構成される複雑なアクションを定義する場所が必要で、かつ他に適切な置き場所がない場合に有用です。Service Objectは、モデルで外部クラスとやりとりするコールバックによって生じる問題(関連記事)を軽減するのにも用いられます。

Service Objectパターンを最大限に利用するための注意点

1. 命名規則を1つに定める

プログラミングで難易度が高いのは、適切かつ意味のわかる名前を割り当てることです。Service Objectの場合、UserCreatorTwitterAuthenticatorCodeObfuscatorなどのように「〜or」で終わる名前を付ける方法が広く採用されています。この命名規則に従おうとすると、OrderCompleterのように英語的に少し無理が生じることがあります。

そして私は、CreateUserAuthenticateUsingTwitterObfuscateCodeCompleteOrderのように、コマンドやアクションを先に書く命名方法ならややわかりやすくなることに気づきました。この方が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).callUserCreator.new.call(params)UserCreator.call(params)のように簡潔に記述でき、読みやすさも向上します。このService Objectはインスタンス化も可能なので、内部のステートを取り出す必要が生じた場合にも有用です。

3. Service Objectの呼び出し方法を1つに定める

個人的にはcallメソッドを使うのが好みですが、他の方法を使わない特別な理由があるわけではなく、performrunexecuteも候補として優れています。重要なのは呼び出し方法を常に統一することです。というのも、クラスの責務は既にクラス名に明示されており、これ以上明確にする必要はないからです。

呼び出し方法を統一しておけば、新しいService Objectを実装するたびに名前を考える面倒がなくなりますし、他のプログラマーは実装の詳細をチェックしなくてもService Objectの使い方をすぐに理解できるという効用もあります。

4. Service Objectの責務を1つに絞り込む

これはService Objectの呼び出し方法を1つに統一しておけばある程度実現できますが、それだけではService Objectに複数の責務が混入する可能性が残ります。Service Objectにさまざまなアクションをまとめることもできますが、アクションのセットは1種類に限定するべきです。

Service Objectでありがちなアンチパターンとしては、たとえばManageUserというserviceがユーザーの作成と削除の両方を引き受けてしまうというものがあります。そもそもmanageという言葉だけでは責務があいまいになってしまい、オブジェクトがどんなアクションを実行すべきかを制御する方法も明確になりません。代わりにDeleteUserCreateUserという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メソッドには関連する手順だけを記述し、それ以外のロジックは最小限に抑えるようにします。andorなどを使って、特定の手順のフローを制御してもよいでしょう。

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」です。

関連記事

Railsの`CurrentAttributes`は有害である(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

リファクタリングRuby: サブクラスをレジストリに置き換える(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ