はじめに
こんにちは。エンジニアの安達です。前回の記事で触れたRailsアプリのリファクタリング対応の一環で、Service Objectの活用も行っていました。
Service Objectは間違った考え方で使うとアンチパターンに陥るとも言われる設計手法です。業務での活用に際して、どういった留意事項や具体的な実装パターンがあるのか詳しく調べたので、RailsでのService Objectの活用方法全般について記事にまとめてみたいと思います。
Service Objectが欲しくなるとき
Rails標準のMVCで業務アプリケーションを実装して行くと、ビジネスロジックが複雑になるにつれて、ControllerまたはModelの処理が肥大化してつらい状態になりやすいです。具体的に言うと、
- 可読性が悪い
- テストが書きづらい
- Modelにメソッドが乱立し、そのModelがビジネス上どういう振る舞いを持つ概念なのか読み取りづらい
このような状態に陥りがちかと思います。この問題を解決する手段の一つとして、 Service Objectを導入する設計手法があります。
Service Objectとは
Service Objectは、Patterns of Enterprise Application Architecture(PofEAA)やドメイン駆動設計(DDD)におけるサービスレイヤを実装に落とし込む設計手法と言えます。
上のリンク先にあるPofEAAの図のように、ユーザーインターフェースとドメインモデルの中間でビジネスプロセスを処理するのがサービスレイヤの役割です。
Service objects are Plain Old Ruby Objects (POROs) which encapsulate a whole business process/user interaction. We rely on these objects in our codebase to remove complexity from the Models and Controller.
サービスオブジェクトとは、ビジネスプロセスやユーザーとの対話の全体をカプセル化するプレーンなRuby Object(PORO)のことです。私たちは、ModelやControllerから複雑さを取り除くために、コードベースでService Objectに依拠しています。
Forem(コミュニティサイト構築OSS。爆速で有名な技術情報交換サイトdev.toはForemで構築されている)の開発者向けドキュメントの上の定義から読み取れるように、RailsではControllerとModelの中間層としてService Objectが導入されることが多いです。
オープンソースのRailsアプリケーションに見るService Objectの具体例
Servie Objectは、オープンソースのRailsアプリケーションを参考にすることで、具体的な実装方法を学ぶことができます。
Servie Objectを活用しているオープンソースのRailsアプリケーションの例
- mastodon/mastodon: Your self-hosted, globally interconnected microblogging community - GitHub
- forem/forem: For empowering community - GitHub
- gitlabhq/gitlabhq: GitLab CE Mirror | Please open new issues in our issue tracker on GitLab.com - GitHub
MastodonのBlockServiceを読む
https://github.com/mastodon/mastodon/blob/main/app/services/block_service.rb
Service Objectの具体的な使い方は、上のリンク先のMastodon(TwitterライクなSNSを各サーバーの連合型として構築するサービス)の BlockService
を見ると分かりやすいです。 UnfollowService
でお互いにフォローを外して、RejectFollowService
でフォローリクエストをリジェクトし、その後ブロックする処理の様子を一見して読み解くことができます。その後に呼ばれている非同期処理の BlockWorker
を辿ると、 BlockWorker
の中で AfterBlockService
が呼ばれていて、ブロックしたユーザーに関わる各種履歴データを削除していることが分かります。
Service Objectの実装方針
オープンソースの実装例や、Webの記事で紹介されている手法を読み解くと、Service Objectには、ベストプラクティスと呼べるような実装方針が一定確立されていることが分かります。それは下記のようなものです。
- Object名を見て処理の概要が理解できるように命名する
call
やexecute
のような、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に引数を渡す
class DoSomethingService attr_reader :arg1, :arg2 def initialize(arg1, arg2) @arg1 = arg1 @arg2 = arg2 end def call # Do something. end end DoSomethingService.new(arg1, arg2).call
これが最も素直な書き方という印象があります。
publicメソッドをインスタンスメソッドとし、publicメソッドに引数を渡す
class DoSomethingService attr_reader :arg1, :arg2 def call(arg1, arg2) @arg1 = arg1 @arg2 = arg2 # Do something. end end DoSomethingService.new.call(arg1, arg2)
Mastodonが採用している方式です。正直上の方式でなくこの方式を採用したい動機が私にはいまひとつ理解できていないのですが、個別メソッドの引数として渡したい意図があるのでしょうか。実装面では initialize
メソッドがない分コンパクトに書けるというのはあるかも知れないです。
publicメソッドをクラスメソッドとする
class DoSomethingService attr_reader :arg1, :arg2 def self.call(arg1, arg2) new(arg1, arg2).call end def initialize(arg1, arg2) @arg1 = arg1 @arg2 = arg2 end def call # Do something. end end DoSomethingService.call(arg1, arg2)
クラスメソッドとして定義すると呼び出し方がスッキリして、RSpecでテストを書く際にService Objectのmockを1行で書けるのも嬉しく(インスタンスメソッドでも2行で作れるので大差はないですが)、個人的にはこの方式を採用したい気持ちがあります。
引数指定が重複しているのが玉に瑕ですが、
class DoSomethingService attr_reader :arg1, :arg2 def self.call(...) new(...).call end def initialize(arg1, arg2) @arg1 = arg1 @arg2 = arg2 end def call # Do something. end end
Ruby 2.7で追加された3点ドットのarguments forwarding記法を用いると、上のようにスッキリ書けます。
Rubyのプレーンなclass(PORO)で実装する
プレーンなclassを使うことで、開発チームメンバーのスキル感を問わず分かりやすく、ライブラリの実装に依存する想定外の動作も起きなくなります。
実際には、ActiveModel::Model
をincludeしてvalidation周りの機能を活用する手法もよく見かける印象があり、その場合エラーハンドリングなどの考え方が変わってくると思うので、どちらがチームやプロダクトにとって望ましいかは確認しておくのが良さそうです。
理想的に活用できた場合のService Objectのメリット
上のような方針に従ってService Objectを活用した場合には、以下のようなメリットがあると感じています。
- ビジネスプロセスに細かい粒度で名前が与えられ、コードの可読性が向上する
- 再利用性のある形で設計することも可能
- チームメンバーのスキル感によらず、設計方針を理解して従うことができる
- ControllerとModelの中間層として、RailsのMVCに後からでも導入しやすい
- 引数と返り値の仕様が定まったclassになるので、テストを書きやすい
簡単(開発の現場には多様なスキル感のメンバーが集うことが想定されるので、優れた設計手法でも理解が難しいと開発チームとしてメンテできないリスクがあると思います)で便利に使えてRailsとの相性も良いということで、現場で導入しやすい設計手法という印象があります。
その一方で、Service Objectは決して銀の弾丸ではなく、間違った使い方をすると悲惨な事態を招きかねない側面も指摘されています。以下に、Service Objectの誤った運用とその改善方法を見ていきたいです。
Service Objectアンチパターン説
- 俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ - Qiita
- Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)|TechRacho by BPS株式会社
Service Objectはアンチパターンとして批判されることも多いです。しかしこれらの批判をよく読むと、Service Object自体がアンチパターンというよりは、その他の設計上の観点を考慮することなく作られたService Objectがアンチパターンであるように読めます。
まずドメインモデルを考慮してからService Objectを作る
ドメインモデル貧血症 - Martin Fowler's Bliki (ja)
多くのOOエキスパートたちが、処理を行うレイヤをドメインモデルの一番上に置いて、サービスレイヤを作るよう推奨していることが、混乱のそもそもの原因です。 ただしこれは、振る舞いのないドメインモデルを作るということではありません。 そうではなくて、サービスレイヤの支持者は、振る舞いをたくさん含んだドメインモデルと一緒に使っています。
サービスの中に振る舞いを見つければ見つけるほど、ドメインモデルのメリットを奪っていくでしょう。サービスの中にすべてのロジックを埋めてしまうと、何も見えなくなってしまいます。
PofEAAの著者のFowlerさんが「ドメインモデル貧血症」と呼ぶような、ModelのメソッドとしてModelに対応するビジネス上の概念の振る舞いが十分に定義されていない状態で、Service Objectを活用してしまうと、それはただの手続き型設計になってしまい、オブジェクト指向設計のメリットをまったく活かせなくなってしまいます。
Railsアプリケーションの場合、以下のような観点で、まずはドメインモデル側の設計を良く検討してみることが大事になるでしょう。
- DBのテーブル構成と各Modelはビジネスの実態に対応しているか
- Modelの汎用的な振る舞いをModel側のメソッドとして定義できているか
- DBのテーブルと1対1に対応しないModelを作って見通しを良くできないか
- 詳しくは、以下の参考記事を参照
Railsでの応用的なModel設計を論じている記事
- 素のRailsは十分に豊かである(翻訳) - TechRacho
- ApplicationModel のある風景 / Rails with ApplicationModel - Speaker Deck
- ActiveRecordのモデルが1つだとつらい - Qiita
- Rails サービスクラス再考 / have a rethink on Rails service class
おわりに
Service Objectを切り口に色々と考えて行く中で、ドメインモデルを重視するPofEAAやDDDの設計思想のコアに辿り着いたような感覚があります。プロダクトもチームも各現場で多様な中、オブジェクト指向設計に正解はなく、プロダクトが扱う実世界の概念をどのようにソースコードに落とし込めば、見通しが良くメンテナンス性が高い状態になるかということを良く考えて設計する必要があるということだと理解しています。
この記事がRailsでのService Object活用の一助になれば幸いです。
その他参考記事
以下の記事も勉強になる内容で、参考にさせていただきました。