はじめに
普段、Ruby on Railsで開発しています。サービスクラスは元々Railsにないクラスですが、ファットコントローラやファットモデルを解消したりするために導入することがあると思います。
上手く使えばファットなコードをスリムにしてくれる便利なサービスクラスですが、一方でこんなサービスクラスはイヤだなと思うこともあります。
どんなサービスクラスがイヤだと思うのか、どうしてそうなるのか、どうすれば防ぐことができるのか、といったことをポエムとしてお伝えしたいと思います。
サービスクラスとは?
chatGPTによると
「ビジネスロジックやデータ処理、外部APIなどの機能を提供するクラスのことです。サービスクラスは、コントローラーから呼び出されることが多く、ビジネスロジックを分離することで、アプリケーションのメンテナンスや拡張性を高めることができます。」
Railsの標準にはないので、app/servicesといったフォルダを用意して独自にクラスを作ることになります。
サービスクラスが生まれるきっかけ
- 共通の処理を使い回したい
- コントローラがファットになった
- モデルがファットになった
- モデルに追加しようとした処理が「自身のテーブル処理」以外の処理が含まれる
- すでにPORO(Plain Old Ruby Object)やドメインモデルなどを導入していて、これから記述しようとする処理をどこに書くか迷った時
- ドメイン駆動開発(DDD)を行なっていて、アプリケーションサービスまたはドメインサービスを作るべきだと判断した時
このようにサービスクラスが生まれるには様々なきっかけがあります。本質的にはどれも可読性を上げたり、テストをし易くしようといった意図があります。その思惑通りにうまくサービスクラスを作れたら良いのですが、実際にはその意図に反して読み辛かったり、テストし辛かったりすることがあります。
Concernで良いのでは?
RailsにはConcernという仕組みがあります。モデルやコントローラのメソッドを切り出してモジュールとして共有・再利用することができます。Rubyのmixinとほぼ同義と考えて良いと思います。
この仕組みを使えばサービスクラスを作るきっかけとなった課題を解決できるのでしょうか?
もしコントローラやモデルのファットを改善しようと考えているのであれば、次の点を考慮した方が良いかもしれません。ファットというのは例えばRuboCopのClass Lengthが超過しているという指摘のことを指していってるのかもしれません。もしそうだとするとClass Lengthが大きいことは何が良くないのかを改めて考える必要があります。
Class Lengthが大きいということは、ひとつのクラスが責務を抱え過ぎているということです。
Concernやmixinを利用すると別のモジュールにコードが移動するので、1つのファイルに書かれるコード量は減っていますが、クラスが抱える責務は減ってはいないということになります。
Concernやmixinを利用する時には、その機能はそのクラスが抱える責務なのか、別のクラスに委譲できないかといったことを検討してから使うのが良いのではないでしょうか。
こんなサービスクラスはイヤだ
- 責務が沢山ある
- プライベートメソッドが沢山ある
- CASE文や条件分岐がガッツリ書かれている
- 子サービスクラスをたくさん呼び出している
- 戻り値を配列やハッシュに詰め込んで返している
責務が沢山ある
オブジェクト指向開発の設計において単一責任の原則という考え方があります。クラスが持つ責任をひとつにすることで、そこにどんな機能を実装すべきか、どんなデータを持つべきかといったことがひとつの責任範囲に収まるように考えることで、コードが読み易くテストし易いものとなるという考え方です。
責任が沢山あるサービスクラスでは、機能が多く、保持しているデータも多くなります。機能もデータも多いためコードを理解したりテストしたりするのが単一責任のクラスに比べて難しくなります。
しかし、単一責任のクラスであっても必ずしも読み易くテストし易いコードになるとは限りません。抽象度の高い責任には注意が必要です。抽象度が高いひとつの責任は、抽象度の低い、より具体的な複数の責任を持っているのと同等のことがあるからです。
抽象度の高い責任を持つクラスの場合、そのクラスの中で詳細な処理まで行うのではなく、そこからさらに別のクラスに処理を委譲し、そちらで詳細な処理を行うことで読み易さテストし易さを得ることができると思います。
プライベートメソッドが沢山ある
先ほど、責任が沢山あるクラス、あるいは抽象度の高い責任を持つクラスは、機能やデータが多くなることがあるとお伝えしましたが、その場合メソッドが長くなったり、プライベートメソッドが増えたりします。プライベートメソッドが多いとテストはやり辛くなります。基本的にテストコードはpublicなメソッドに対して行うからです。クラスの中で詳細な処理まで行うのではなく、そこからさらに別のクラスに処理を委譲し、そのクラスのpublicメソッドとすることでテストが容易になります。
CASE文や条件分岐がガッツリ書かれている
サービスクラスにCASE文や条件分岐がガッツリ書かれているということは、次の2つの状況が考えられます。ひとつはきちんと単一責任の原則に乗ってコードが記述されているので、これらのCASE文や条件分岐はこのサービスクラスに書くべきものが必然的に書かれている状況。もうひとつはここに書くべきではないコードなのだけれど、他に書くべき良い場所が分からないからこのサービスクラスに書いてしまっている状況。後者は手続き型で記述されている場合によく起こりがちな状況だと思います。
手続型は悪いことではありませんが、もしそのCASE文や条件分岐がコードのあちらこちらに存在するのであれば、それは良くない傾向です。DRYではないですし似たような条件のテストを何度も書かなければなりません。
手続型のままCASE文や条件分岐のコードを共通化・再利用できるように、さらにモジュールに分割するか、あるいは思い切ってオブジェクト指向の多態性(ポリモーフィズム)を使った記述への変更を検討することをお勧めします。
子サービスクラスをたくさん呼び出している
サービスクラスがファットになったりテストがやり辛くなってくると、さらにモジュール分割を行うことがあります。手続き型で分割するのか、オブジェクト指向の考えで分割するのか、それぞれ考え方があると思います。
サービスクラスを分割する際、子サービスクラスへの分割はお勧めしません。子サービスクラスが読み辛い理由のひとつとして、それが子なのか親なのかサービスクラスという分類からは分からないからです。命名規約やコメントで対応するという手もあるかもしれませんが、そこまでしてサービスクラスにカテゴライズする必要があるのか疑問です。子サービスクラスが生まれるのは、他に良い受け皿が見当たらないというのが理由なのではないでしょうか。
もし、それが理由なのであれば子サービスクラスではなく、次のようなところへの記述を検討してはどうでしょうか。ユーティリティーメソッドを集めたモジュール、PORO、モデル。
ファットモデルから分割してサービスを作ったのに、またモデルに記述するのはおかしいと思うかもしれませんが、子サービスについては元々のモデルの責務から引き剥がし過ぎている可能性があるため、それが元に戻るという状況もあり得るということです。
戻り値を配列やハッシュに詰め込んで返している
これはサービスクラスに限ったことではないのですが、戻り値に沢山の情報を詰め込みたい場合にやってしまいがちなこととしてお伝えしたいと思います。
この説明の前にRailsのコントローラーからビューに沢山の情報を返却する時のことを考えたいと思います。実装方法としてよくあるのがビューに渡すべき値が出てくる度にインスタンス変数を宣言して受け渡しする方法です。インスタンス変数は多くなると管理が大変です。Rubyは動的型付け言語であり、インスタンス変数は宣言しなくてもいきなり代入したりして使うことができます。そのためインスタンス変数をタイポすることで発生するバグはデバッグが厄介なことがあります。
また、ビューから見るとインスタンス変数はグローバル変数と同義なので、インスタンス変数は少ないに越したことはありません。
このようなインスタンス変数ですが、コントローラのメソッドをサービスクラスへとコードを移した場合、コントローラのメソッドにあったインスタンス変数をそのままサービスクラスのインスタンス変数に置き換えたとしてもビューから直接参照することはできません。サービスクラスからコントローラー、コントローラからビューといった具合に戻り値をリレーしなくてはなりません。このリレーを簡単にするために、複数あったインスタンス変数をまとめてハッシュや配列に詰め込むという方法が取られることがあります。(もちろん複数形のデータを扱うという意味の配列は何の問題もありません)
受け渡しを簡単にするために使われるハッシュや配列は、その場面は簡単であっても次のようなデメリットを生みます。一番厄介なのはサービスクラスの中を読まないとどんな戻り値が詰め込まれているのか把握し辛いことです。DTO(データトランスファーオブジェクト)やレスポンスオブジェクトあるいはPOROやStructなどを使うことを検討しましょう。これらは宣言的なのでどういった戻り値があるのかコード読み解かなくても明示してくれます。またこれらはリファクタリングが可能です。これは開発する上でとても心強いことです。一方でハッシュや配列はリファクタリングに弱いのです。
サービスクラスのアンチパターン
サービスクラスで避けた方が良いと言われるアンチパターンを挙げておきます。
- ファット
- トランザクションスクリプト
- ゴッドオブジェクト
- ドメインモデル貧血症
- 状態保持
ファット
前述の「責務が沢山ある」と重なりますが、ファットなコードは複雑でテストし辛くなります。コードが長いということはそれだけ他とコードが重複する部分が出てくる可能性が高くなるということです。コードの重複する部分が出てくるということはコードの再利用性が低下しているということです。
トランザクションスクリプト
サービスクラスに詳細なロジックを書き過ぎることです。ドメインモデルなど他のオブジェクトに書くべきコードをあるべき場所から奪ってサービスクラスに書いてしまっている状態です。前述のファットであることプライベートメソッドを持ち過ぎることなどと合わせて気をつけたいです。
この名前だけ見ると手続き型が良くないような印象を持つかもしれませんが、このアンチパターンが伝えたいことはコードの小さな断片の再利用についてだと考えています。再利用可能な小さなコードの断片をどこに記述するべきか。モデルなのかユーティリティーなのか。それがどこが適切なのかは検討の余地がありますが、サービスクラスには置くべきではありません。サービスクラスに閉じ込めてしまうのは再利用とテストのし易さの観点から良くないという意味でアンチパターンになっていると思います。
ゴッドオブジェクト
サービスクラスが対象アプリケーションのほとんどすべてのビジネスロジックを持っている状態です。コードが複雑化し、可読性が低下し、テストが困難で、メンテナンスも困難になります。
ある機能を実現するのに1つの巨大クラスだけで実装されているのは良くない状況ですが、1つの機能にだけ影響を与えているのはまだ救いがあります。1つの巨大なクラスが複数の機能にまたがって影響を与えている状況、ゴッドオブジェクトはとても恐ろしい状況です。
ドメインモデル貧血症
ドメインモデル貧血症はファットモデルとは反対の状態を表します。本来モデルは単一責任の原則に従いながらデータを保持し、そのデータを操作するためのメソッドが持つものです。ファットモデルは複数の責任を持つことでコードが肥大化する問題です。ドメインモデル貧血症はモデルに書くべきメソッドをサービスクラスなど他のクラスに記述してしまって、モデルに保持するデータを操作するのに必要なメソッドが不足している状態のことを表します。データとメソッドがセットになっていることで、オブジェクト指向の重要な要素である「カプセル化」を実現することができます。データだけがあってメソッドが無い・不足しているといった状態では「カプセル化」の恩恵を受けることはできません。モデルのメソッド不足を貧血症に例えたアンチパターンです。このような状態はデータ構造と手続きが分離していた時代、オブジェクト指向が導入される前の状況へと退化しています。
状態保持
サービスクラスは状態を保持しません。状態を保つのはモデル、値オブジェクトなどの役目です。サービスクラスはそういった複数のオブジェクトに対して指示を出す司令塔の役割を担います。
状態は保持しませんが、インスタンス変数を保持してはいけないわけではありません。サービスクラスの状態を保持するのではなく、他のレイヤー例えばrepositoryオブジェクトなどを利用するためにそのインスタンスを保持することは問題ではありません。
サービスクラスにアトリビュートを持たせて複数の引数を渡したり複数の戻り値を受け取るのに使うコードを見たことがありますが、サービスクラスが状態を持っているかのような誤解を与えるのでお勧めしません。引数や戻り値をやり取りするのが目的なのであれば素直にDTO、PORO、Struct、モデルなどを使いましょう。
どのサービスクラスを書こうしている?
今から書こうとしているサービスクラスは次のどのタイプのサービスクラスになりますか?
どういったタイプのサービスクラスを書くかを考えることで、際限なく広がっていきがちなサービスクラスの責務に制約を与えることができるかもしれません。
- DDD:アプリケーションサービス
- DDD:ドメインサービス
- トランザクションスクリプト
- PofEAA:サービス
DDD:アプリケーションサービス
アプリケーションのユースケースを書こうとしている。
ドメイン層にあるドメインサービス、エンティティ、リポジトリ、集約などを利用して一つの処理としてまとめようとしている。
トランザクションやセキュリティを取扱いたい。
ビジネスロジックは直接記述せずに他のオブジェクトに委ねる。
DDD:ドメインサービス
ドメインに記述すると不自然になるようなコード。例として挙げられることが多いのは「重複データのチェック」など。
上記のアプリケーションサービスとは違って、ここにはビジネスロジックを記述することができます。
PofEAA:サービス
ドメインモデルとユーザーインターフェースの間でビジネスプロセスを処理するもの。ビジネスロジックを記述することができます。
前述のDDD:ドメインサービスはドメインモデルありきで考えて、ドメインモデルに記述することが不自然なものをドメインサービスに書くというスタンスでした。PofEAA:サービスはそういった縛りはあまり無さそうな印象です。そのためドメインモデル貧血症の原因になったりするようです。
トランザクションスクリプト
データと手続きが分離している。手続き型プログラミンで実装する方法。ドメインモデルやPOROなど他のクラスと協調することをほとんど考えない。クラス設計しなくて良いので素早く実装に取りかかれる。
Railsとの干渉
Railsは高機能なフレームワークです。Railsに対してドメイン駆動開発やPOROなどを導入しようとするとRailsの考え方と干渉することがあります。その場合、干渉を避けてRailsだけで開発を進める方法と、Railsと共存を図る方法、他のフレームワークを使う方法があるかと思います。
他のフレームワークとして気になるのは「Hanami」ですね。RailsよりHanamiの方がドメイン駆動開発との親和性は高いようですが、こちらはこちらで手放しでドメイン駆動開発を導入できるわけでもないようですし、そもそもRailsの恩恵を捨ててこちらに乗り換えるのは現実的に難しそうです。
Railsとの共存を考えた場合、大きく干渉すると思われる次の2箇所について述べたいと思います。
- モデル
- コントローラ
モデル
Railsのモデルはひとつのテーブルに対する責任を持っています。この切り分けは小規模開発では便利なのですが、大規模な開発になるにつれて厄介な問題になります。テーブルではないただの「モノ」、ファイル、外部ストレージなどであっても本来はモデル化の対象となり得るはずです。さらに言うと永続化とモデルは一体である必要もありません。ですが、Railsのモデルはビジネスロジックとテーブルへの永続化を一体とする設計を基本としています。
また、Railsのモデルでは単数形も複数形も1つのモデルで取り扱っています。1レコードのカラムやバリデーションを取り扱いつつ、複数レコードを取得するためのscopeなども取り扱っています。
ドメイン駆動開発などでは、Entity、Repository、集約などに分担する責務をRailsのモデルは1つのクラスで実現しています。Railsのモデルがファットモデルになった際に、いきなりサービスに分割することを考えがちですが、Entiy部分、Repository部分、集約部分に分割することを考えてみるのも良いかもしれません。
Railsのモデルはどこまで行ってもテーブルを主軸に考えなければならないので、「モノ」として振る舞うモデルを考えた場合、Railsのモデルには思想的な縛りがあります。
このような縛りを回避するためにドメインモデルをRailsモデルとは別クラスとして用意して、RailsモデルはRepositoryのような永続化に特化する考え方もあるかもしれません。
もちろんこれらややこしいことをすっ飛ばしてサービスクラスに頼るのも手だとは思います。この場合は、考え方をオブジェクト指向から手続き型へと寄せていることを理解した上で進めるべきだと思います。これはカプセル化や多態性といったオブジェクト指向がもたらすメリットとのトレードオフになるでしょう。
コントローラ
Railsのコントローラはリクエストを受け取ってモデルやビューと連携してレスポンスを返す役割があります。トランザクションや例外を制御することもできます。
Railsのコントローラには、アプリケーションサービスクラス、ドメインサービスクラス、トランザクションスクリプトなどに記述するような内容をそのまま記述することができます。MVCに厳密に従うのであればコントローラにビジネスロジックを書くべきではないのですが、コード量が少なく、可読性が高く、変更が容易、テストし易い、というのであれば特に大きな問題は無いでしょう。
厳密にレイヤを分けるために例外なくサービスクラスを用意するのがベストなのであれば、Railsは、rails generateでサービスクラスを自動生成していることでしょう。例外なくレイヤを差し込むことよりも、柔軟に記述できるのがRailsらしさなのかもしれません。
このようなRailsのコントローラですが、アプリケーションサービスクラス、ドメインサービスクラス、トランザクションスクリプトなどの内容を記述するサービスクラスと干渉することがあります。どこでトランザクションを制御すべきか、ビジネスロジックをどこに記述すべきか、といったことについて悩むことになります。
コントローラとモデルの間に必ずサービスクラス層を差し込むというのは、層が分離するので一見きれいに見えますが別の課題を生み出します。課題のひとつは引数・戻り値の受け渡しです。前述したような問題を持つハッシュや配列などは使いたくないので、それ以外の方法である、DTO、レスポンスオブジェクト、モデル、Structなどへの詰め直し、あるいは基本データ型を使って各層をまたぐことになるでしょう。また単純であっても必ずサービスクラスを作らなくてはなりません。
コントローラからサービスクラスへ分離することを考える際、引数・戻り値の受け渡しと別途サービスクラスを用意するコストを考えて判断する必要がありそうです。また分離を検討する際の判断材料のひとつとしてRuboCopのClass LengthやMethod Lengthが使えそうです。
こんなサービスクラスであってほしい
ここまでサービスクラスについて気になる点を書いてきましたが、どんなサービスクラスだったら良いのかを書いておきます。
- ロジックらしいロジックが書かれていない
- 複数のオブジェクトに対する司令塔
- ステートレス
- テストコードはMockだらけ
ロジックらしいロジックが書かれていない
アプリケーションサービスクラスにはビジネスロジックは書かないものなので、基本的にロジックは書かないようにしたい。
ドメインサービスクラスはビジネスロジックを書く場所なのでロジックを書けば良いのですが、そうであってもモデルに記述できる部分はモデルに集めておきたいですね。
複数のオブジェクトに対する司令塔
ロジックらしいロジックが書かれていないのであれば何を書くのか?ということになりますが、関連するオブジェクトのメソッドを呼び出すことに注力したい。自分自身は細かなことは行わなずに複数のオブジェクトに対する司令塔のように振る舞っているのが理想的です。
ドメインサービスクラスについては細かなことも記述しなければならない場面があると思います。それでも極力モデルと協調して動作したいものです。
ステートレス
状態は持たないようにしたい。repositroyやfactoryなど他のレイヤーオブジェクトを初期化時に渡すのは問題ありません。しかし、サービスクラスが状態を保持するようになると、それはもはやサービスクラスではなく何らかのモデルだと思われます。
テストコードはMockだらけ
もし、ロジックらしいロジックがなくて他のオブジェクトを呼び出すばかりなのであれば、テストコードはMockだらけになってしまいます。必要なオブジェクトに正しい指示を出していることがサービスクラスに求められることなので、それで良いんじゃないかと思います。
最後に
普段、自分でコードを書いたり、他の人のコードをレビューしたり、既存コードを読んだり、リファクタリングしたりしながら、どうもファットなサービスクラスが多いなあと気になっていました。そしてサービスクラスって、どうあるべきなんだろうと調べたり試行錯誤したりしていました。上手くまとまってはいないですが、備忘録として残しておこうと思います。
この記事を書いていて気になったのですが、サービスクラスといってもアプリケーションサービスクラスとドメインサービスクラスでは性質が異なるので、どちらもサービスクラスという呼び名は混乱を招いているなと感じました。
アプリケーションサービスクラス → ユースケースクラス
ドメインサービスクラス → サービスクラス
こちらの方がわかりやすい気がしました。
参考
俺が悪かった。素直に間違いを認めるから、もうサービスクラスとか作るのは止めてくれ
RailsでのService Objectの上手な使い方