モジュールか、サービスクラスか

created_at:
updated_at:

この記事は SUPER STUDIO Advent Calendar 2024 の2日目の記事になります。

目次

業務で遭遇した問題

module NameSpace
	module ApplicationHelper
	  def a
	    case x
	    when 'other'
	      a_service.call
	    when 'common'
	      # とあるデータを取得する共通処理を呼び出したい
	  end
	  
	  def b
	    case x
	    when 'other'
	      b_service.call
	    when 'common'
	      # とあるデータを取得する共通処理を呼び出したい
	  end
	  
	  def c
	    case x
	    when 'other'
	      c_service.call
	    when 'common'
	      # とあるデータを取得する共通処理を呼び出したい
	  end

[前提]

  • applicationHelperはapplicationControllerでincludeされている。
module NameSpace
  class ApplicationController < ::ApplicationController
    include ApplicationHelper
  • applicationControllerは全コントローラーの親クラスである
module NameSpace
  class HogeController < ApplicationController
  • ApplicationHelperの中身にはサービスクラスを呼び出す処理が記述されている
    • フロントエンド(テンプレートエンジン側)に渡す必要のあるデータ自体が多く、かつ呼び出しケースも2パターンあるため分岐が必要。処理自体が複雑化してしまうため、複雑な処理をサービス層に切り出すということを行っている

共通処理をモジュールにすべきか、サービスクラスにすべきか

以下どちらの選択を取るべきなのか

  1. 共通処理をmodule化してa_service, b_service, c_service内でincludeし、a_service.instance_methodというふうにインスタンスメソッドとして呼び出す
  2. 共通処理はclass化(サービスクラス)し、各サービス内でdef instance_methodを定義し、その中でサービスクラスとして切り出した共通処理をクラスメソッドとして呼び出す(最終的なApplicationHelper上での呼び出し方は1と同じになる)

自分の中で、1, 2のどちらかを採用するうえでの判断基準が曖昧だと感じた。

モジュールはもう少し大きな汎用的なものを切り出すイメージがあるというのがチーム内で意見としてあがった。

この「大きく汎用的な処理」とは一体どのような処理を指すのか?というのが自分には言語化ができなかったので詳しく調べてみた。

👀聞いてみた

chatGPTに以下問いを投げかけてみました。

共通処理をモジュールとして切り出してインスタンスメソッドとして各クラスに付与するためにincludeするか、共通処理をサービスクラスとして切り出し、各呼び出し元でメソッドを定義し、その中で共通処理のサービスクラスを呼び出すというふうにするか迷っています。どちらを選ぶべきなのかの判断基準について教えて下さい

以下が回答である。

モジュールとしてincludeする場合

module CommonMethods
  def common_method
    # 共通処理
  end
end

class SomeClass
  include CommonMethods
end

利点:

  1. メソッドの共有: 共通のメソッドを複数のクラスで共有し、それらのクラスのインスタンスメソッドとして使用できる。
  2. コードの再利用: 同じコードを複数の場所で使い回せるため、コードの重複を避けることができる。
  3. 簡潔なクラス設計: クラス内に直接インクルードするため、呼び出しが簡潔でわかりやすい。

欠点:

  1. 名前空間の汚染: モジュール内のメソッドがクラスの名前空間に直接追加されるため、名前の衝突が発生する可能性がある。
  2. テストの難易度: インスタンスメソッドとして組み込まれるため、単体テストが難しくなることがある。

適用例:

  • 複数のクラスに共通するメソッドがあり、それらのメソッドが各クラスのインスタンスメソッドとして自然に使える場合。
  • 名前空間の衝突が発生しにくい場合。
  • インクルードするメソッドの数が比較的少ない場合。

サービスクラスとして切り出す場合

class CommonService
  def self.common_method
    # 共通処理
  end
end

class SomeClass
  def some_method
    CommonService.common_method
  end
end

利点:

  1. 単一責任の原則: サービスクラスは特定の処理に特化しており、クラス設計がシンプルで責任が明確になる。
  2. テストのしやすさ: サービスクラスは独立しているため、単体テストが容易になる。
  3. 依存の注入: 呼び出し元でサービスクラスのインスタンスを作成し、必要に応じて依存を注入できる。

欠点:

  1. 呼び出しの複雑化: 各クラスでサービスクラスのインスタンスを生成し、メソッドを呼び出す必要があるため、コードが少し複雑になる。
  2. 追加のクラス管理: サービスクラスとして切り出すことで、管理するクラスの数が増える。

適用例:

  • 複数のクラスで共通する処理があり、その処理が単一の責任として切り出せる場合。
  • 名前空間の衝突を避けたい場合。
  • 共通処理が多くのメソッドや複雑なロジックを含む場合。

この時点での判断基準

  • 処理の責任範囲:
    • 共通処理がそのクラスの自然な一部として機能するなら、モジュールとしてinclude
    • 共通処理が独立した責任として扱えるなら、サービスクラスとして切り出し。
  • 名前空間の管理:
    • 名前空間の衝突が懸念される場合、サービスクラスとして切り出す。
    • 名前空間の衝突が問題にならない場合、モジュールとしてinclude
  • テストの容易さ:
    • テストが重要な場合、サービスクラスとして切り出すことでテストが容易になる。
    • テストが複雑でない場合、モジュールとしてinclude
  • メソッドの数と複雑さ:
    • 共通処理がシンプルでメソッドの数が少ない場合、モジュールとしてinclude
    • 共通処理が複雑でメソッドの数が多い場合、サービスクラスとして切り出す。

上記判断基準を今回の問題に当てはめて考えてみると

  • 処理の責任範囲 ⇒ どちらにあたるのか判断がつかない
  • 名前空間の管理 ⇒ 名前空間の衝突は懸念にならない ⇒ どちらでもOK
  • テストの容易さ ⇒ テストできたほうが嬉しい
  • メソッド数と複雑さ ⇒ 共通処理自体は現時点では我慢できるレベルで今後拡張される場合には複雑だなと感じる程度になりそう

この時点でサービスへの切り出しを行ったほうが良さそうという判断がつけられそう。

確信を得るために、上記回答内の観点「処理の責任範囲」についてさらに深堀りしてみた。

「共通処理がそのクラスの自然な一部として機能するなら」とは?

これは、共通処理がそのクラスの基本的な機能や動作に密接に関連している場合を指します。つまり、そのクラスが持つべき当然の機能の一部であり、クラスのインスタンスメソッドとして自然に振る舞うものです。

(例)ユーザー認証システム

複数のモデル(User, Admin, Moderator)が共通してパスワードのハッシュ化機能を持つ

module PasswordEncryptable
  def encrypt_password(password)
    # パスワードをハッシュ化する処理
    Digest::SHA256.hexdigest(password)
  end
end

class User
  include PasswordEncryptable

  def initialize(password)
    @password = encrypt_password(password)
  end
end

class Admin
  include PasswordEncryptable

  def initialize(password)
    @password = encrypt_password(password)
  end
end

class Moderator
  include PasswordEncryptable

  def initialize(password)
    @password = encrypt_password(password)
  end
end

この場合、encrypt_passwordメソッドは、各クラスの自然な一部として機能します。各クラスがインスタンスメソッドとしてencrypt_passwordを持ち、初期化の際に使用しています。共通の機能をモジュールとして切り出し、各クラスでincludeすることで再利用性を高めています。

共通処理が独立した責任として扱えるなら」とは?

共通処理が特定の機能やロジックに焦点を当てており、クラスの動作やデータと直接結びつかない場合を指します。この処理は、他のコンポーネントやクラスから独立して動作し、単一の責任を持つサービスとして切り出すことができます。

(例)支払い処理システム

異なるモデル(Order, Subscription, Invoice)が共通して支払いの計算処理を必要とする

class PaymentCalculator
  def self.calculate(amount, discount = 0)
    # 支払いを計算する処理
    amount - (amount * discount)
  end
end

class Order
  def initialize(amount)
    @amount = amount
  end

  def total_payment(discount = 0)
    PaymentCalculator.calculate(@amount, discount)
  end
end

class Subscription
  def initialize(amount)
    @amount = amount
  end

  def total_payment(discount = 0)
    PaymentCalculator.calculate(@amount, discount)
  end
end

class Invoice
  def initialize(amount)
    @amount = amount
  end

  def total_payment(discount = 0)
    PaymentCalculator.calculate(@amount, discount)
  end
end

この場合、PaymentCalculatorクラスは支払い計算という独立した責任を持ち、Order, Subscription, Invoiceの各クラスから呼び出されます。共通処理をサービスクラスとして切り出すことで、他のクラスに依存せずに単体でテストやメンテナンスがしやすくなります。

判断基準の詳細

  1. 共通処理がそのクラスの自然な一部として機能する場合:
    • 処理がそのクラスの基本的な役割や振る舞いの一部として必要。
    • 処理がそのクラスの他のメソッドと密接に関連している。
    • モジュールとしてincludeすることで、そのクラスのインスタンスメソッドとして自然に使える。
  2. 共通処理が独立した責任として扱える場合:
    • 処理が特定の機能やロジックに焦点を当てており、クラスの他の部分と直接結びつかない。
    • 処理が独立して動作し、他のクラスから呼び出される。
    • サービスクラスとして切り出すことで、単一責任の原則に従い、テストやメンテナンスが容易になる。

「自然な一部」についての見解

共通処理ではあるが、基本的な役割や振る舞いの一部でしかなく、その処理自体が何かしらアプリケーションの機能のメイン処理として位置づけるほどではない処理という理解をした。

ハッシュ化処理と計算処理の比較が確かに感覚的にわかりやすい。

ハッシュ化したとてエンドユーザー側がその恩恵を直接感じられるということには繋がらなさそう。一方で計算処理はエンドユーザーに対して直接的な影響が大きいという感覚が自分の中ではある。

また「自然な一部」に関する別の解釈として、「普遍性の度合い」なのかなと思った。

パスワードのハッシュ化という機能はどれだけアプリケーションの仕様が変わろうとも必ず行わなければならないレベルの処理な気がしている。やって当たり前のことというレベルの処理。

一方で今回の共通処理はデータ取得処理であり、アプリケーションの仕様変更があっても絶対に変わらないか?と問われるとyesとは言い難い。

「独立した責任」についての見解

今回の共通処理はフロント側のとあるパーツを生成するために必要なデータ取得処理であった。

そのため、「パーツを生成する」という独立した責任を持っている(ないしはその責任に対して今回の処理がカバーする範囲が大きい)というふうに捉えられると考えた。

パスワードのハッシュ化は独立した責任とは異なるのは、おそらくその処理単体ではサービスとしての一機能を満たすことになっていないからだと思った。(ユーザー認証完了までの過程の一部でしかない。)

どんな判断基準が良さそうか?

もし自分が同じような場面に遭遇した場合は、以下観点にどのくらい当てはまるか?判断基準を元にコードに関わっていきたいと思った。

  • 処理の責任範囲
    • モジュール
      • その処理は、基本的な役割や振る舞いの一部である。
        アプリケーションの仕様に依らず、やって当たり前レベルの処理をしている。
        仕様変更の影響を受ける可能性が低い
    • サービスクラス
      • その処理自体が独立した責任を担っている。
        仕様変更の影響を受ける可能性が高い。
  • 名前空間の管理
    • モジュール
      • 名前空間の衝突が問題にならない
    • サービスクラス
      • 名前空間の衝突が懸念される
  • テスト容易性
    • モジュール
      • テストがそれほど複雑にならない
    • サービスクラス
      • テストを重視したい
  • メソッド数と複雑度
    • モジュール
      • 処理がシンプルでメソッド数が少ない
    • サービスクラス
      • 処理が複雑でメソッド数が多い(privateメソッドが2個以上できる)

後日談

記事公開後、会社の方からもう少し詳しく教えてもらったことがあり、新しく追記したいことが出てきたので、まとまり次第追加していこうと思います。

Buy Me A Coffee