この記事は クラウドワークスグループ - Qiita Advent Calendar 2024 シリーズ1の7日目の記事です。
クラウドソーシングサービス「クラウドワークス」の技術的負債の継続的解消を目的としたジャンヌチームでエンジニアとして働いているlinyclarです。
はじめに
先月、こんな資料が話題になりました。
「今のプロジェクトいろいろ大変なんですよ、app/services とかもあって……」/After Kaigi on Rails 2024 LT Night - Speaker Deck
弊サイトのコンテンツをご覧になればわかる通り、こういう話題には私も一家言あるので、アドカレのネタもないしapp/servicesについて語ってみようと思います。
ただ、先に結論を言ってしまえば、app/servicesが有害かどうかは、プロジェクトの複雑度とエンジニアのスキルによると考えています。「アプリケーションのコアの外核としてアプリケーションが何ができるのかを明確にできる」というapp/servicesを作ることによるメリットを享受できるかどうかにかかっているとも言えます。
また、app/use_casesなども同じものとして扱います。
app/services とは
DDD的に...というよりDDDで用いられるアーキテクチャパターンにはサービスレイヤ(ユースケースレイヤ)というレイヤがあります。これは、アプリケーションのユースケースを表現するためのレイヤです。DDD的に適切に実装されたならば、app/servicesにはユースケースの各ステップ、あるいは1つのユーザーストーリーに対応するクラスが配置されることになります。
つまり、理屈上は適切に実装できていればapp/servicesの中を確認することで、そのアプリケーションの全体像を掴むことができるということです。
有害になる側面: 過剰設計
しかし、現実的には過剰設計になることが多いのも事実です。「ユーザーが記事を削除する」というユースケースに対して、UserDeleteArticleService
というクラスを作るなら以下のようになるでしょう。
class UserDeleteArticleService
attr_accessor :user
def initialize(user)
@user = user
end
def can_delete?(article_id)
user.articles.exists?(id: article_id)
end
def delete(article_id)
raise '事前条件違反' unless can_delete?(article_id)
user.articles.find(article_id).destroy
end
end
class ArticlesController < ApplicationController
def destroy
service = UserDeleteArticleService.new(current_user)
unless service.can_delete?(params[:id])
flash[:error] = '削除済みか削除権限がありません'
redirect_to articles_path
return
end
service.delete(params[:id])
redirect_to articles_path
end
end
このようなコードはControllerにベタに書いた方がシンプルでわかりやすくなります。
class ArticlesController < ApplicationController
def destroy
article = current_user.articles.find_by(id: params[:id])
if article.blank?
flash[:error] = '削除済みか削除権限がありません'
redirect_to articles_path
return
end
article.destroy
redirect_to articles_path
end
end
このケースでapp/servicesが有害となるのは、このような記述は過剰設計だと気づける判断力がないか、レイヤがあるのだからServiceを作らなければならないという理由だけでServiceを作ってしまう場合です。
無用だから作らないという判断ができるのであれば、この点においてはapp/servicesは有害になりません。
有害になる側面: トランザクションスクリプトパターン
また、app/servicesが有害になるもう一つの例として、トランザクションスクリプトパターンがあります。
FatControllerに比べれば、Controllerの役割と分離できているという点でトランザクションスクリプトパターンなServiceクラスはマシといえます。
しかし、トランザクションスクリプトパターンで組んでいるとServiceクラスが肥大化し可読性が悪くなります。また、処理を共通化しようとすると、Serviceクラスが別のServiceクラスに依存するようになってしまい、どれがControllerに対して公開されているのかがわかりにくくなります。
トランザクションスクリプトパターンでもよいケースは、「Controllerに書くには複雑だが、そのControllerからしか使われないからModelに書くのも微妙である」という場合です。しかし、それは他のServiceクラスがまともに記述できている状態における下限のServiceクラスであり、そういう状態でないならapp/servicesは作らずにModelにクラスメソッドを書く選択した方が良いと考えます。
FormObjectを作るという選択肢もありますが、Rails Wayから外れるならPOROにしたいという考えが強いので、私はFormObjectが選択肢に入っていません。
有害になる側面: レイヤの増加
Railsの良いところは、URLを見ればDBのテーブルに至るまで中心となるものの当てがつくということです。そして、app/views、app/helpers、app/controllers、app/modelsなど、それぞれのディレクトリの役割は明確で何を書けば良いのかはジュニアにもわかりやすくなっています。
しかし、app/servicesが増えると、このルールが崩れてしまいます。Serviceに書くべきか、Modelに書くべきか、Controllerに書くべきか、その判断が難しくなります。
また、URLを見てもどのServiceクラスが呼ばれるのかがわかりにくくなります。ArticlesController
から呼ばれるのはapp/services/articles/
以下のクラスだけというルールを作ることもできますが、それは単にControllerを二段に分けただけにしかなりません。
つまり、Rails Wayに従っていてURLから見るべきModelを特定できるという利点を享受できているうちは、app/servicesを使うべきではないと考えます。あるいは、URLから対応するユーザーストーリーを読み取れているうちは、app/servicesは不要でしょう。
場合によっては有用になる側面: 単一のController以外からも利用される処理の分離
例えば、ユーザー画面と管理画面の双方で実行できるような処理がある場合、Controllerに書くと重複が生じます。そのような場合、app/servicesに書くことで重複を避けることができます。
あるいは、Controller以外にバッチから呼ばれるような処理がある場合もapp/servicesは有用になります。
開発環境の処理という意味でいうと、rails consoleから呼び出すことでブラウザ操作をせずに実際の処理に基づくテストデータを作ることができますし、FactoryBotから呼び出すことで実際の処理によって作られたデータでテストを行うこともできます。
ただ、これはController以外に処理を書けば良いという意味であり、直接的にapp/servicesの利点とは言えません。
場合によっては有用になる側面: 飛び抜けて複雑な処理のエントリポイント
例えば、複数のModelをまたいだ処理で「このModelが処理の中心である」と、どのModelに対しても言い難い場合、app/servicesに処理を書くという選択肢が出てきます。
このような場合、URLからModelを特定するということはできないし、特定のModelの責務外の処理が混じることを避けるためにも、app/servicesに書くことが適切でしょう。
ただ、Serviceクラスの数が少ないなら、app/modelsに入れてしまう方が良いかもしれません。
有用なケース: プロジェクト全体がURLからユーザーストーリーを読み取れないほど複雑
app/servicesを作るべき一番の理由はこれです。プロジェクト全体がURLからユーザーストーリーを読み取れないほど複雑な場合、app/servicesを使うことでユーザーストーリーを明確にすることができます。
仮に、以下のようなServiceがあるとします。
- BlogOwnerCreateArticleService
- BlogOwnerUpdateArticleService
- BlogOwnerDeleteArticleService
- LoggedInUserCreateCommentService
- BlogOwnerDeleteCommentService
これらのServiceがこのシステムの全てだとするなら、このブログシステムはログインしたユーザーしかコメントをできないし、コメントしたユーザーが編集することも削除することもできず、ブログオーナーだけがコメントを削除できるというのがわかります。
これがDDDの優れているところです。ユースケース(ユーザーストーリー)というドメインモデルとコードを対応づけることで、サービスレイヤ(ユースケースレイヤ)レベルで何ができるのかがコードを見ればわかるようになります。
このようにapp/servicesを作ることによるメリットは、アプリケーションのコアの外核としてアプリケーションが何ができるのかを明確にできることです。
また、ApplicationServiceにアクター名(上記の例のBlogOwnerやLoggedInUser)を含めることは一般的ではありませんが、アクター名を含めることでより役割が明確になるため、私はつけるべきだと考えています。
あとは、全部のユースケース(ユーザーストーリー)に対応するServiceを書くかですが、組織の意思決定次第でしょう。冒頭の例のような単純なCRUD処理の割合が少ないなら全部Serviceを用意するという選択肢もあり得ますし、単純なCRUD処理で済むような機能は取るに足らないと考えてServiceを作らないという選択肢もありえます。
まとめ
結局、app/servicesが有害かどうかは、プロジェクトの複雑度とエンジニアのスキルによります。「アプリケーションのコアの外核としてアプリケーションが何ができるのかを明確にできる」というapp/servicesを作ることによるメリットを享受できるかどうかにかかっているとも言えます。
Rails Wayに乗っていて、config/routes.rbを見ればプロジェクト全体のユーザーストーリーが読み取れるならapp/servicesは作らない方が良いでしょう。
読み取れないのだとしても、Rails Wayに寄せる形で読み取れる状態に持っていけるなら、そちらの方が望ましいはずです。
DDDを実践できない、つまりユースケース(ユーザーストーリー)に対応するものとしてServiceクラスを作るということを理解できない、実践できないのならapp/servicesを作るのは諦めた方が良いでしょう。
いわゆる軽量DDDでDDDのアーキテクチャパターンだけ導入しようと考えてapp/servicesを作るのならば、有害になる側面として挙げた負の面、とくに過剰設計に陥る可能性が高いのでapp/servicesを作らない方が良いでしょう。