RailsでのService Objectの上手な使い方 ―Service Objectアンチパターン説の検討―

業務のRailsアプリのリファクタ対応の一環でService Objectを入れてみたところ、なかなか快適に使えている感触なので、RailsでのService Objectについて私なりに調べたことをまとめてみたい。アンチパターンとして批判されることも多いService Objectについて、どういった使い方をするとマズいのかも掘り下げてみる。

Service Objectが欲しくなるとき

Rails標準のMVCで業務アプリケーションを実装して行くと、ビジネスロジックが複雑になるにつれて、ControllerまたはModelの処理が肥大化してつらい状態になりやすい。具体的には、

  • 可読性が悪い
  • テストが書きづらい
  • Modelにメソッドが乱立し、そのModelがビジネス上どういう振る舞いを持つ概念なのか読み取りづらい

これらの観点が挙げられるだろう。この問題を解決する手段の一つとして、 Service Objectを導入する設計手法がある。

Service Objectとは

Service Objectは、Patterns of Enterprise Application Architecture(PofEAA)やドメイン駆動設計(DDD)におけるサービスレイヤを実装に落とし込む設計手法と言える。

https://www.martinfowler.com/eaaCatalog/ServiceLayerSketch.gif

上のPofEAAの図のように、ユーザーインターフェースドメインモデルの中間でビジネスプロセスを処理するのがサービスレイヤの役割だ。

Service objects are Plain Old Ruby Objects (POROs) which encapsulate a whole business process/user interaction.

サービスオブジェクトは、ビジネスプロセスやユーザーとの対話の全体をカプセル化するプレーンなRuby Object(PORO)。

Forem*1の開発者向けドキュメントの上の定義もシンプルで分かりやすい。

オープンソースRailsアプリケーションに見るService Objectの具体例

Servie Objectは、オープンソースRailsアプリケーションに理想的な活用例を見出すことができる。

Servie Objectを活用しているオープンソースRailsアプリケーションの例

MastodonのBlockServiceを読む

Service Objectの具体的な使い方は、上のリンク先のMastodon*2BlockServiceを見ると分かりやすい。UnfollowServiceでお互いにフォローを外して、RejectFollowServiceでフォローリクエストをリジェクトし、その後ブロックする処理の様子をぱっと見で読み解くことができる。その後に呼ばれている非同期処理のBlockWorkerを辿ると、BlockWorkerの中でAfterBlockServiceが呼ばれていて、ブロックしたユーザーに関わる各種履歴データを削除していることが分かる。

Service Objectの実装方針

オープンソースの実装例や、Webの記事で紹介されている手法を読み解くと、Service Objectには、ベストプラクティスと呼べるような実装方針が一定確立されていることが分かる。それは下記のようなものだ。

  • Object名を見て処理の概要が理解できるように命名する
  • callexecuteのような、1つだけ定義されたpublicメソッドを呼び出して利用する
  • privateメソッドを細かく切って処理の概要を理解しやすくする
  • 単一責任の原則を意識してService Objectを分割し、Service Objectの中で他のService Objectを呼び出す
  • Rubyのプレーンなclass(PORO)で実装する

以下、詳細を補足したい。

Objectの命名

  • 動詞 + (名詞) + Service
    • FollowService
    • RemoveStatusService
  • namespaceを分けるパターン
    • Commits::CreateService

Object名がビジネスプロセスの実態を呼び出し先で表現するように命名することが大事だろう。namespaceは分けておいた方が予期せぬ要件の拡張があっても秩序を保ちやすい印象がある。

publicメソッド名の選択肢

call, execute, run, performなど、特定の文脈を想起させないメソッド名を採用する。個人的には、callカプセル化されたビジネスプロセスを呼び出すニュアンスを最も良く表している気がして好み。

publicメソッドの定義方法

publicメソッドの定義方法は上に挙げた3つのRailsアプリにおいてもバラバラで、特にどれかが決定的に優れているとも言えないので、方式を統一しようとすると悩ましい部分になるだろう。以下に代表的な例と、各パターンについての私見を書く。

publicメソッドをインスタンスメソッドとし、newに引数を渡す

これが最も素直な書き方という感じがする。

publicメソッドをインスタンスメソッドとし、publicメソッドに引数を渡す

Mastodonが採用している方式。正直上の方式でなくこの方式を採用したい動機が私にはいまひとつ理解できないけれど、initializeメソッドがない分スッキリして見えるというのはあるかも知れない。

publicメソッドをクラスメソッドとする

クラスメソッドとして定義すると呼び出し方がスッキリするし、RSpecでテストを書く際にService Objectのmockを1行で書けるのも嬉しく*3、個人的にはこの方式を採用したい気持ちがある。引数指定が重複しているのが玉に瑕だけれど、

Ruby2.7で追加された3点ドットのarguments forwarding記法を用いると、上のようにスッキリお洒落に書ける。

Rubyのプレーンなclass(PORO)で実装する

プレーンなclassを使うことで、開発チームメンバーのスキル感を問わず分かりやすく、ライブラリの実装に依存する想定外の動作も起きない。

ActiveModel::Modelをincludeしてvalidation周りの機能を活用する手法もあるようだけれど、業務でService Objectを運用してみた感触でも、POROで見通し良く実装するのがService Objectの持ち味を活かせる気がしている。

理想的に活用できた場合のService Objectのメリット

上のような方針に従ってService Objectを活用した場合には、以下のようなメリットがあると感じている。

  • ビジネスプロセスに細かい粒度で名前が与えられ、コードの可読性が向上する
  • 再利用性のある形で設計することも可能
  • チームメンバーのスキル感によらず、設計方針を理解して従うことができる
  • ControllerとModelの中間層として、RailsMVCに後からでも導入しやすい
  • 引数と返り値の仕様が定まったclassになるので、テストを書きやすい

簡単*4で便利に使えてRailsとの相性も良いということで、非常に優れた設計手法という感じだ。

その一方で、Service Objectは決して銀の弾丸ではなく、間違った使い方をすると悲惨な事態を招きかねない側面もある。以下、Service Objectの誤った運用とその改善方法を見ていきたい。

Service Objectアンチパターン

Service Objectはアンチパターンとして批判されることも多い。しかしこれらの批判をよく読むと、Service Objectがアンチパターンというよりは、その他の設計上の観点を考慮することなく作られたService Objectがアンチパターンであるように読める。

まずドメインモデルを考慮してからService Objectを作る

多くのOOエキスパートたちが、処理を行うレイヤをドメインモデルの一番上に置いて、サービスレイヤを作るよう推奨していることが、混乱のそもそもの原因です。 ただしこれは、振る舞いのないドメインモデルを作るということではありません。 そうではなくて、サービスレイヤの支持者は、振る舞いをたくさん含んだドメインモデルと一緒に使っています。

サービスの中に振る舞いを見つければ見つけるほど、ドメインモデルのメリットを奪っていくでしょう。サービスの中にすべてのロジックを埋めてしまうと、何も見えなくなってしまいます。

PofEAAのFowlerさんが「ドメインモデル貧血症」と呼ぶような、ModelのメソッドとしてModelに対応するビジネス上の概念の振る舞いが十分に定義されていない状態で、Service Objectを活用してしまうと、それはただの手続き型設計になってしまい、オブジェクト指向設計のメリットをまったく活かせなくなってしまう。

以下のような観点で、まずはドメインモデル側の設計を良く検討してみることが大事だろう。

  • DBのテーブル構成と各Modelはビジネスの実態に対応しているか
  • DBのテーブルに1対1に対応しないModelを作って見通しを良くできないか
    • Railsの場合、ActiveModel::Modelをincludeする手法が有効
  • Modelの汎用的な振る舞いをModel側のメソッドとして定義できているか

RailsでModelを分割する手法について論じている記事

おわりに

Service Objectを切り口に色々と考えて行くと、ドメインモデルを重視するPofEAAやDDDの設計思想のコアも見えてきた気がする。プロダクトもチームも各現場で多様な中、オブジェクト指向設計に正解はなく、プロダクトが扱う実世界の概念をどのようにソースコードに落とし込めば、見通しが良くメンテナンス性が高い状態になるかということを良く考えて設計する必要があるということになるだろう。

その他参考にした記事

*1:コミュニティサイト構築OSS。爆速で有名な技術情報交換サイトdev.toはForemで構築されている。

*2:TwitterライクなSNS構築サービス。

*3:インスタンスメソッドでも2行で作れるので大差はないが。

*4:様々なスキル感のメンバーが集う日本の開発の現場においては、優れた設計手法でも理解が難しいと開発チームとしてメンテできないリスクがあると思う。