🌟

Railsプロジェクトにおけるロジック処理

2023/03/16に公開

はじめに

株式会社ヴァージニアのエンジニアリング本部の津留です。前々回と前回で投稿しました記事ではヴァージニアのRailsプロジェクトで使用しているパラメータクラスとコントローラクラスについて書かせていただきました。その流れに続いて、今回はヴァージニアではロジック処理をどのように書いているのか、について触れた記事にしたいと思います!
記事の流れとしてはRailsプロジェクトでアンチパターンとして挙げられているロジックの実装処理について見て行き、次にヴァージニアで実際に行っているロジックの処理の方法と、その方法によって感じる個人的に感じたメリットについて書いていきます。

アンチパターン

ファットモデル

ファットモデルとは

前回の記事でも少し触れましたがRailsではスキニーコントローラーが良しとされ、コントローラではあくまで認証と認可、サービスクラスの呼び出し、テンプレートレンダリングの責務に集中すべきだと述べました。一方でスキニーコントローラを目指すがあまり、ロジックをモデルクラスに集中して書いていくことで、モデルクラスで複数のユースケースを考慮しなければならなくなり、複数の責務を持つようになってしまいます。これがファットモデルの状態です。

ファットモデルの解決策としての例

1. 他モジュールに移譲

ファットモデルの解決策として挙げられるのがモデルに記述された処理や他のモデルと共通して使用できる共通処理などを他のモジュールクラスに移譲することでモデルクラスのスリム化を図る方法です。ActiveSupport::Concernを使用して共通処理等をモデルクラスにインクルードして使えるようにする例がよく見受けられます。
しかし、この方法ではモデルクラスのコード量を減らす目的でなら有効ではありますが、結局コードをモジュールに移譲させた後、インクルードしているので単一責任の原則に違反していることには変わりありません。DRYを実現すると言う観点からですと良い方法ではあると思いますが、ファットモデルの解決には向かないように思えます。

2. 単一テーブル継承

2つ目の例は単一テーブル継承(STI)を使用する方法です。Railsにおいてモデルとテーブルは原則、1対1の関係ですがSTIを使用すれば1つのテーブルに対して複数のモデルクラスを紐づけることが出来ます。STIはRailsガイドのシングルテーブル継承で実際の実装方法が述べられています。
この方法を用いれば、モデルクラスごとにバリデーションやコールバック処理を分けることができる為、抽象的なモデルを具象化するために分割するという観点ではスリム化は実現できそうです。しかし、この方法はテーブル設計にまで影響が及び、実装レベルの解決には至っていません。さらに継承クラスと継承元クラスの関係が親子の関係である必要があるので使用場面が限定的であるという点、テーブル設計の話なので運用途中から導入するのは困難である点、そもそもSTIを導入する事自体があまり好ましくないのではないか等、いくつかの問題点が挙げられます。

3. ユースケースごとにモデルクラスを継承

3つ目にユースケースごとにモデルクラスを継承する方法が考えられます。
具体的にはテーブルと1対1の関係にあるモデルクラスUserクラスがあるとします。今まで複数のユースケース、責務が一つのモデルクラスに実装されてしまうことが問題でしたが、この方法では一つの責務をもつ別のクラスを作成し、Userモデルクラスを継承させます。
例えばUserを更新(Update)するケースがあった場合、Userクラスを継承したUesr::AttributeUpdatorクラスを作成し、UserUpdateする場合のみのバリデーションの制約やコールバックを実装するということです。こうすることでUesr::AttributeUpdatorクラスを更新時にのみ、使用すれば単一責任の原則に乗っ取ったクラスができるのでファットモデル問題を解消できると思われます。
しかし、本来の継承の使い方は親クラスと子クラスがis-aの関係になっていることが好ましく、今回のようにユースケース毎にクラスを分けて親クラスを基底クラスの様に使うのは、保守性に問題があります。オブジェクト指向の思想から外れてしまっているので可読性が下がり、将来的なバグの原因にもなりうるでしょう。さらに、長い期間使われてきたアプリケーションの基底クラスを変更すると影響範囲も膨大になる恐れがありますので、できれば避けたいところです。

ヴァージニアのロジック処理

上記ではファットモデルを避ける為の方法について述べてきましたが、次はヴァージニアではファットモデルを避ける為に、如何にロジックを実装しているのか紹介させて頂きます。

1. サービスクラスの導入・特徴

ヴァージニアではロジックをモデルクラスに書くことはせず、サービスクラスを導入してユースケースごとにサービスクラスを生成しロジックを実装する方針をとっています。ここでモデルにロジックを書かないとお伝えしましたが、全く何も書かないと言うわけではなく、モデルのインスタンスが持つ自身の情報を成形するメソッドや、自身の情報からフラグを返却するメソッドなど、自身の性質や振る舞いを表現するメソッドは持たせています。あくまで複数の責務をもたせないようにすることが目的なので、ある特定のユースケースでしか使用しないようなロジックは持たないようにしている、と言う意味です。

具体的に実装例を見てみましょう。下記のコードをご覧ください。

class DetermineCustomerPlanService
  include Service

  def initialize(customer_id:, plan_grade:)
    @customer_id = customer_id
    @plan_grade = plan_grade
  end

  def call
    @customer.update!(plan_grade: @plan_grade)
    generate_service_plan(plan_grade: @plan_grade).call(resource: @customer.resource_for_plan)
  end

  private

  def generate_service_plan(plan_grade:)
    case plan_grade
    when "premium"
      CustomerPremiumPlanService #希望するプランのグレードがプレミアムの顧客に対し、プレミアムプランを設定するサービスクラス
    when "basic"
      CustomerBasicPlanService #希望するプランのグレードがベーシックの顧客に対し、ベーシックプランを設定するサービスクラス
    else
      NotImplementedError
    end
  end
end

顧客の希望するプランのグレードがpremiumなのかbasicかによって顧客に紐づくプラン内容を決定しているクラスになります。クラス名から推測できる通り、顧客のプランを決定するユースケース限定のサービスクラスとなります。
なのでこのクラス自体、単一責任の原則に則っていますし、顧客(Customer)クラスにも顧客に紐づくプラン(Plan)クラスにも今回のユースケースのロジックを実装しなくて済むことになります。
単一責任の原則にも則っているので、ある処理を実装している途中で、顧客のプランを決定する処理を挟む必要が出てきた場合には今回例示したサービスクラスを呼び出せば実現できるので保守の観点からも良いです。generate_service_planメソッドの内部では、まさに顧客の希望するプランのグレードによってCustomerPremiumPlanServiceCustomerBasicPlanServiceのいずれかが呼び出されるようになっていますね。これら二つのサービスクラスもプレミアムプランを決定するサービスクラスとベーシックプランを決定するサービスクラスと責務を一つに集中しているので、処理がしっかり分割できており、再利用性が高くなっています。
これらのロジックをモデルクラスを使って実現しようとすると、単一責任の原則から外れてしまっているのは勿論、ロジックをCustomerモデルクラスに書くべきなのかPlanモデルクラスに書くべきなのか迷ってしまいます。どちらに書くか迷うということは、モデルの振る舞いとして扱うべきではないと判断できると言えます。なので、サービスクラスにロジック処理を切り出して、一つのクラスの持つ役割をできるだけ、細分化した方が良いと思います。

2. サービスクラスを作成する際の約束事

上では一つのサービスクラスの実装例を例示させていただきましたが、ヴァージニアで実装されたサービスクラスにはいくつかの共通な特徴があるので、それらを紹介したいと思います。
まず一つ目にPublicなメソッドにはinitializeメソッドとcallメソッドのみを用意していると言う点です。上でも述べた通り、サービスクラスは一つのユースケースを実現するように作成しておりますので、外部からはクラスをインスタンス化して実行する挙動のみを行える様にしてあります。
二つ目には上のコード例にもあるとおり、Serviceモジュールをインクルードしている点です。下記はServiceモジュールのコードです。

module Service
  extend ActiveSupport::Concern

  class_methods do
    def call(*args, **kwargs)
      new(*args, **kwargs).call
    end
  end
end

ServiceモジュールではActiveSupport::Concernを使用しています。ここでActiveSupport::Concernの細かい説明は省きますが、モジュール内でクラスメソッドのcallが定義されています。
メソッドの内部を見ていただくとわかりますが、このモジュールをインクルードしているクラスをインスタンス化せずに直接callメソッドを呼び出すと、メソッド内部でnewメソッドを呼び出し、クラスをインスタンス化し、インスタンスメソッドのcallメソッドを呼び出しています。
つまり、上のサービスクラスをコントローラから呼び出した場合下記のように、逐一インスタンス化せずに簡略化して呼び出すことが可能になります。

class CustomersController < ::Api::BaseController
  def update
    # ...中略
    customer = DetermineCustomerPlanService.call(customer_id: customer_id, plan_grade: plan_grade)
    # ...中略
  end
end

3. モデルクラスに実装している処理

ロジックをモデルクラスに書かないのは上記まででたくさん述べてきましたが、ではモデルクラスには何を主に書いているのか述べていきたいと思います。
ヴァージニアのロジック実装を述べる最初の方でも軽く触れましたが、モデルクラスはエンティティとほぼ同義だと捉え、エンティティ自身を表現する属性や振る舞いをモデルクラスには定義しています。
例えば、先ほどコード例で示したDetermineCustomerPlanServiceクラスの中で、下記部分がありました。

generate_service_plan(plan_grade: @plan_grade).call(resource: @customer.resource_for_plan)

generate_service_planメソッドで返したサービスクラスでcallメソッドを実行する際に@customerインスタンスでresource_for_planメソッドを実行していますが、このメソッドでプランを作成する際に必要な顧客の情報を返していると考えてください。
返す顧客の情報はCustomerクラスにとって、自身が持つ性質を指し、プランを作成する際に必要な顧客の情報を返すという振る舞いを持っている、と言うことになります。

まとめ

今回、ロジック処理のアンチパターンと解決策、最後にヴァージニアの実装方法について述べてきました。
ヴァージニアのロジック実装は、記事中にも書いてきましたが、各クラスがある特定のユースケースを想定して作られているので処理が追いやすく、可読性が高い点が個人的には気に入っています。またクラス名に関しても今回一例しか示せていませんが、何を実行するためのクラスなのかわかりやすく命名されており、実行処理が想像しやすいです。ある特定のユースケースを想定しているからこそ、クラス名もわかりやすく名付けることができると言っていいかもしれません。
さらに、処理を追加実装するクラスが明確であり、各サービスクラスで実装されているメソッドが同じ作りになっている(「2. サービスクラスを作成する際の約束事」を参照)のでメンテナンス面で利点があります。用途が絞られているので一クラスあたりのコードの量がそこまで多くならないのも個人的に読みやすいです。
個人的な経験上、ヴァージニアのようにユースケースを意識して、クラス内部のメソッドの作りまで決まり事がある現場はなかったので、初見時は非常に新鮮でした。今まで経験してきた現場でのサービスクラスについては、どのクラスに、どんな実装を持たせるのか曖昧なケースが多かったのでレビュー時に議論したり、開発時に考えなくてはならない時間が多かったのですが、現在はあまり手を止まらせることなく開発出来たので、開発時のストレスが大きく解消されると思います。
ロジック導入する際に参考にしてみてはいかがでしょうか。

VIRGINIA Tech Blog

Discussion