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

Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)

概要

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


whitefusion.ioより

画像は元記事からの引用です。

  • 2018/04/18: 初版公開
  • 2022/01/20: 更新

Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)

近年、RailsアプリにService Objectを追加するメリットを説く記事が次から次へと量産されています。私は本記事において、それがなぜ正しくないかを述べたいと思う次第であります。もっとよい方法はあるのです。

私はこれまで、Service Objectに関するネット上の議論にときおり参加しては、問題に対するまっとうな解決方法としてService Objectが正しくない理由について繰り返し見解を述べてきました。実際、私は多くの場合においてService Objectよりもっとよい解決方法があると考えるのみならず、Service Objectはオブジェクト指向設計原則への配慮が損なわれている兆候を示すアンチパターンとして取り扱っています。

Service Objectを追加したところであんたのRailsコードベースが今より良くなることはねえと言ったらどうするよ?

Service Objectを追加したところで
あんたのRailsコードベースが今より良くなることはねえと言ったらどうするよ?

このような深遠なポイントを細切れのツイートやコメント欄を追って理解するのは大変です。そこで私は、私の見解を正確に表すいくつかの現実的なコードを詳しく追って記事にすることにしました。

私が使う「アンチパターン」という用語については、StackOverflowにうまい説明があったので引用します。

「アンチパターン」とは、ソフトウェア開発においてプログラミング上の悪手と考えられる特定のパターンのことです。デザインパターンが、よい開発手法と一般的に考えられている定型化された共通のアプローチであるのと対照的に、アンチパターンは望ましくないものです。
https://stackoverflow.com/a/980616より

私がService Objectを好きになれない理由を説明するために、昔ある顧客のプロジェクトで当時の開発チームが書いたコードを私が継承した中から、いくつかを詳しく見ていくことにします。そのアプリは未だにpublic betaなので当時の状況についてはあまり立ち入ったことは書けませんが、メディア(画像や動画)にレーティングするソーシャルプラットフォームであり、レーティングは特定のコールバックスタイルの操作(データ処理のアルゴリズム更新や、さまざまなユーザーのタイムラインへのアクティビティの追加など)でトリガされる、とだけ言っておきましょう。

比較的シンプルなデータモデルが1つあり、UserオブジェクトとMediaオブジェクトにbelongs_toで属しているRatingオブジェクトをそこに作成できます。以下のコード例はいずれもproductionファイルそのままではなく、抜粋です。

class Rating < ActiveRecord::Base
  belongs_to :user
  belongs_to :media
end
class Media < ActiveRecord::Base
  has_many :ratings
end

以下を見ればおわかりのように、前任の開発者は、ユーザーから届くレーティングを扱うのにMediaRatingというService Objectをこしらえたのです。これはコントローラから呼び出されます。

class MediaRating
  def self.rate(user, media, rating)
    mr = MediaRating.new(user)
    rating_record = mr.update_rating(media, rating)
  end

  def initialize(user)
    @user = user
  end

  def update_rating(media, rating)
    rating_record = @user.ratings.where(media: media).first
    if rating_record.nil?
      # 何か作成する
    else
      # 何か更新する
    end

    # データのアルゴリズム処理の実行やら
    # タイムラインでのソーシャル活動の追加やらを行う
  end
end

対応するコントローラのコードは次のとおりです。

media = Media.find(params[:media_id])
rating = params[:rating].to_i
MediaRating.rate(current_user, media, rating)

このコードが最初に書かれたのはかなり前であることを念頭に置いてください。最近のService Object愛好者たちなら、提示するAPIをもう少し形式的に書くので、このService Objectを私が書き直してもよいのであれば、きっとこう直すことでしょう。

# これはGemfileに追加
gem 'smart_init'

class UserMediaRater
  extend SmartInit

  initialize_with :user, :media, :rating
  is_callable

  def call
    rating_record = @user.ratings.where(media: @media).first
    # etc.
  end
end

# コントローラからの更新済みコマンド
UserMediaRater.call(current_user, media, rating)

問題が始まるとき

私のコードは満更でもありませんよね?そこそこクリーンですし、よく構造化され、楽にテストできます。私がService Objectを書き直したコードを今皆さんが見ているわけですが、問題はこの洗練されたコードが実際より相当簡略化されたものだという点です。コードベース内の実際のコードは74行ものスパゲッティコードになっていて、メソッドが別のメソッドを呼び、それがまた別のメソッドを呼んでいるという具合です。というのも、データのアルゴリズム処理だのタイムライン更新だのが全部1個のService Objectに押し込められていたからです。実際のフローは以下のような具合でした。

コントローラ > Service Object > Rateメソッド > Ratingの更新 > 何か別の更新メソッド + (アルゴリズムの実行 > 関連データの更新)、そしてキャッシュの無効化 + アクティビティをタイムラインに追加

そんなわけで、私がコードベースをエディタで新たに開いて、ユーザーがレーティングしたメディアオブジェクトの作成や更新を行っているコードのブロックをちょっと見ようとするたびに、本質的でない大量の付加機能をかき分けて基本のコードパスにたどり着かなければなりませんでした。

「ちょっとちょっとぉ、さすがにその開発者のService Objectの書き方はいくらなんでもマズいでしょ!他のオブジェクトに(おそらく他のService Objectであっても)そうやって処理を押し込めるんじゃなくて、もっとシンプルかつ対象を絞り込んで書けばよかったのに」とおっしゃる方もいるでしょう。

まあまあ、もうしばらくお付き合いください。標準的なRails MVCパターンに含まれるコードを抽出してService Objectに置く必要があると言われていた本来の理由は、要するに複雑なコードフローを単独の関数に分割しやすくなるからだということでした。しかしここで問題なのは、ルールを強制するものが何もないということです。これっぽっちもないんです!シンプルなService Objectを書くことは間違いなく誰でもできます。しかし、メソッドをたらふく抱え込んであっという間にスパゲッティコード化してしまう複雑なService Objectも、同じぐらい間違いなく誰にでも書けるのです。

私が何が言いたいかおわかりでしょうか?つまり本質的にService Objectパターンそのものには、コードベースを読みやすくする力も、メンテしやすくする能力も、関心を適切に分離する手腕もありはしないということです。

あるパターンが、シンプルなものから複雑なものまでほぼ無限に、ほぼあらゆる種類のプログラミングスタイルを無節操に許容して促進するのであれば、そんなものは開発者にとって最早有用なパターンでも何でもありません。

ではどうすればいいんでしょうかね?

私がそこそこの量のコードを書き始めるときは(データを受け取って、他の機能に配慮しながら作成なりレコード更新なりを行わなければならないぐらいの規模感であれば)、大抵の場合最も適切なモデル上に(訳注: わざと)クラスメソッドを1つ書くところから始めます。まま、そう慌てないで。これがService Objectの上を行く優秀なパターンと言いたいのではありません。「ここにうまくはまるパターンがないかなぁ」と私があれこれ考えるよりも前にこれを行っている点にご注目いただきたいのです。

もし仮に、Rating自身のクラスメソッドを用いてメディアのレーティングを行っていたとしたら果たしてどうなっていたか、見てみましょう。

class Rating < ActiveRecord::Base
  belongs_to :user
  belongs_to :media

  def self.rate(user, media, rating)
    rating_record = Rating.find_or_initialize_by(user: user, media: media)
    rating_record.rating = rating
    rating_record.save

    # データのアルゴリズム処理の実行やら
    # タイムラインでのソーシャル活動の追加やらを行う
  end
end

コントローラのコードはこんな感じに書き直します。

media = Media.find(params[:media_id])
rating = params[:rating].to_i
Rating.rate(current_user, media, rating)

私はこのコードを目にして、やっと安堵の息をつくことができました。というのも、レーティングのコードが直接Rateモデルに置かれていることで、この機能がコードの影響を最も強く受けるデータ構造に近い場所に配置されているからです。このコードベースをエディタで開いてレートの処理を見たければ、Rateモデルを見ればよいのです。実に素直なコードです。

とはいうものの、このコードにはまだ1か所、非常にうれしくない点があります。要は、私は(クラスメソッドではなく)インスタンスメソッドを呼ぶことと、可能な限りRailsの関連付けを使うのが好きなのです。今のコードは私にとって、あたり一面クラスメソッドだらけになる「コードの匂い」と、本来意図されている関連付けや標準的なOOP原則を回避したがる「コードの匂い」がプンプン漂ってきます。この場合、コントローラで@media.rateなどと書けないなんて残念です。要するに私はメディアのオブジェクトを立ち上げてそれをレーティングしたいのです。こんな明確なインターフェイスをなぜ使えないんだと思うわけです。

真の友はconcernとPORO

次はモデルのクラスメソッドから複雑なコードを切り出す必要があると確信できたら、ガラクタをモデルのインスタンスメソッドに押し込めるようなパターンなんかではない、もっとよいパターンを見つけたいと思います。いわゆる「ファットモデル」に伴う問題とは、突き詰めれば、分割したコードの置き場所としてみんなが真っ先にService Objectを推奨するのは一体なぜか、ということです。

しかし、現実にはファットモデルのデメリットは、1個のオブジェクトに大量のメソッドを書くことのデメリットに比べればたかが知れています。後者は、そうした大量のメソッドが(そしておそらく関連する単体テスト)1つのファイルでひしめき合うことです。しかし、現実には単一のオブジェクトが多くのメソッドを持つ「ファットモデル」のデメリットはさほどではなく、そうした多くのメソッドは(そしておそらく関連する単体テストも)すべて1つのファイルにまとまります。本当に必要なのは、ある重要な機能のコード片が、別の重要な機能のコード片に干渉しないようにしてコードを理解しやすくするための手法であり、そしてどのコード片を別のオブジェクトに再配置してまとめるかという何らかの経験則なのです。

さて、このメディアレーティング業務で何ができそうかちょっと見てみましょう。私なら最初に、立ち向かうべきコードのひと塊をconcernに切り出すでしょう(concernとは、標準的なRubyのmixinをRailsで少々拡張しただけのものです)。このconcernにRatableという名前を付けましょう。

module Ratable
  extend ActiveSupport::Concern

  included do
    has_many :ratings
  end

  def create_or_update_user_rating(user:, rating:)
    rating_record = ratings.find_or_initialize_by(user: user)
    rating_record.rating = rating
    rating_record.save

    # データのアルゴリズム処理の実行やら
    # タイムラインでのソーシャル活動の追加やらを行う

    rating_record
  end
end

このMediaクラスにも嬉しい点があります。has_many :ratingsディレクティブを取り出してこの新しいconcernに置いておけるからです。

class Media < ActiveRecord::Base
  include Ratable
end

コントローラのコードはこんな感じに書き直します。

rating = params[:rating].to_i
Media.find(params[:media_id]).create_or_update_user_rating(
  user: current_user,
  rating: rating
)

既にかなり改善された感がありますね。後はコントローラでメディアのオブジェクトを検索して、わかりやすい名前のインスタンスメソッドを1つ呼び出せばおしまいです。可能な限り最善の手法によってRailsらしさにあふれた、親しみやすいインターフェイスです。

しかしまだ問題が1つ残っています。
このcreate_or_update_user_ratingメソッドが頑張りすぎています。ここではデータベースアクセスを扱うのが自然ですが、データのアルゴリズム処理だのタイムラインの更新だのは、結果が出てからトリガされるべきですし、別の場所で定義されるべきアクションのように思えます。

標準のRails wayなら、このコードをActiveRecordのコールバックに置くところでしょう。コールバックに置くのは別に問題ありませんし、うまくはまりそうなら私は喜んで使います。しかしこの場合は、主に行うべき2つの機能が、それと隣合わせになっている特定のメディア、レーティング、ユーザーオブジェクトと関連しているだけで、それ以外に何の関連もないように思えます。

そこで、この機会にドメインモデルを少々適用し、余分な機能をconcernからさらに切り出して別のPORO(pure old Ruby object)に切り出してみましょう。create_or_update_user_ratingメソッドは、これらの新しいオブジェクトを指すようにして小ざっぱりかつシンプルにします。

  def create_or_update_user_rating(user:, rating:)
    rating_record = ratings.find_or_initialize_by(user: user)
    rating_record.rating = rating
    rating_record.save

    # 追加機能をPOROまたは関連するモデルに切り出しましょう
    # バックグラウンドジョブにカプセル化すれば
    # さらに改善できますね
    # 読者の練習用に取っておきます
    Rating::Processor.run(rating_record)
    Timeline::Activities.add_for_rating(rating_record)

    rating_record
  end

もはや考えるまでもなく、Rating::ProcessorTimeline:: ActivitiesもService Objectではありません。これらはPOROであり、OOPパラダイムに注意深く配慮しながらモデリングされています。オブジェクトの1つは私が「Processorパターン」と呼んでいるもので、入力を取って処理し、どこかに出力を保存します。もう1つは「Collectionパターン」で、項目の追加や削除と、それらのアクションの結果を管理します。ここでは特別なことや独自の技は何ひとつ使われていません。しかしそこが重要なのです。

ここで代わりにService Objectパターンの利用を試みることもやろうとおもえばやれました。その場合おそらく、UserMediaRaterをリファクタリングしてProcessNewRatingAddTimelineActivityForRatingなどのService Objectを呼び出す形になるでしょう。しかし、concernとPOROの読みやすさと良い構造化にどれだけ太刀打ちできるでしょうか?本質的には関数であるものがぎっしり詰まったapp/servicesフォルダを見て心が折れるくらいなら、クラス名とデータ構造と、そして読みやすく使いやすい設計のオブジェクトメソッドを備えた本物のドメインモデリングを手にすればよいのです。

最後に申し上げたいポイントは「Service ObjectではなくconcernとPOROを使うことで、インターフェイスが改善され、関心(concern)が正しく分離され、OOP原則が健全に使われるようになり、コードを把握しやすくなる」です。

テストの戦略について語る余地がなくなってしまいましたが、concernやより高度なPOROを使うと、テストでService Objectとはまた別の問題が持ち上がるのではないかとご心配な方のために、有用なリソースをいくつかご紹介します。

Railsのconcernをモデルレベルやコントローラレベルで有用なPOROパターンと組み合わせることが、こんなときに使われがちなService Objectと比べていかに優れているかについてはお話したいことがいくらでもありますので、今後の記事をぜひお楽しみに。

忙しい方向けのまとめ: Service Objectはアカンやつであり、ほとんどの場合もっとよいソリューションがあります。どうかそちらをお使いください。お読みいただきありがとうございました!

(罵倒でない、十分吟味した)ご意見・ご感想は@jaredcwhiteまでどうぞ 😊

お知らせ

Intersectは、Whitefusion社が提供するJekyllベースのオープンなWebブログです。弊社についての詳細や弊社の目指すものについては会社概要をご覧ください。

付録: 社内Slackより

ServiceObjectに複雑なロジックを委譲(切り取り&ペースト)だけしてもアカン。PORO設計などのモデリングをしないとキレイにならないよと言っていそう。


私がよくやるのはこれかな: 「標準のRails wayなら、このコードをActiveRecordのコールバックに置くところでしょう。コールバックに置くのは別に問題ありませんし、うまくはまりそうなら私は喜んで使います。」
よくオブジェクトAのafter_saveでオブジェクトBのデータを更新してオブジェクトBのafter_saveにオブジェクトAのデータ更新が書いてあって、無限ループとかあるので、このあたりがうまくはまる/はまらないってところなのかなあ。


Service Objectをcomplex facadeとして使う場合、facadeの先がある程度パッケージとして固まっていてくれるとここまでごちゃらない気がしますが、Rubyだと苦しいかなあ。
Javaで言うprotectedみたいなのでうまくServiceの先がカプセル化されていて、不用意に直接呼び出しされないようになっていればいいんですが、Rubyだとオープンクラスだし、強制しづらそう。
service objectだけがその先のオブジェクトを触れる、みたいな強制の仕方をしようとすると、マイクロサービスみたいな話になるのかなあ。

ツイートより

関連記事

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

Rails tips: Service Objectパターンでリファクタリング(翻訳)

Rails: Service Objectはもっと使われてもいい(翻訳)


CONTACT

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