Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

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

概要

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

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

  • 2017/10/16: 初版公開
  • 2022/03/17: 更新

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

Service Object(単にServiceと呼ばれることもあります)は、肥大化したActiveRecordモデルを分割し↓、コントローラをスリムかつ読みやすくするうえで非常に有用な、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はさらに薄いレイヤを提供しています。

Selleo/business_process - GitHub

Selleo/pattern - GitHub

次回のpart 2は「Query Object」です。

関連記事

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

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

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


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。