一定の制約を設けてRailsのサービスクラスに一定の秩序を設ける

Railsでのサービスクラス(またはオブジェクト)の是非はさておき、Railsでサービスクラスは導入するとルールがゆるふわになりがちだったりします。

例えば、「サービスクラスを実行するメソッドはクラスメソッドなのか、メソッド名は execute なのか、perform なのか、 call なのか… どれがいいとか悪いとかはないですが、これらに対して一定の制約を設けることで、サービスクラスに対して一定の秩序を与えていきましょう。

今回設定するサービスクラスへの制約について

  1. 必ずベースとなるmoduleをincludeすること。
  2. クラス名には必ず「Service」を含める。(含めないとエラーになるようにしています。)
  3. initialize は必ず定義して、インスタンス化させる。
  4. 必ず、クラスメソッドからperform サービスクラスを実行する。

まず、サービスクラスのベースになるServiceBaseというモジュールを実装します。

module ServiceBase
    class ServiceClassRuleError < StandardError; end

    def self.included(base)
        base.extend(ClassMethods)
        raise ServiceClassRuleError.new("#{base.name}: Please Rename Service Class") unless base.name.include?("Service")
    end

    module ClassMethods
        def perform(*args)
            self.send(:new, *args).send(:perform)
        end
    end

    private

    def initialize
        raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
    end

    def perform
        raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
    end
  end

このベースになるモジュールの役割としては、

  1. サービスのクラス名に Service という名称がクラス名に含まれているかをチェックします

  2. initalize メソッドを必ず定義させるために、未実装の場合にエラーになるようにしています。

  3. perform インスタンスメソッドを必ず定義させる。このインスタンスメソッドはperform クラスメソッドからのみCallされるようにするために、気休め程度ですが、プライベートメソッドにしておきます。

  4. クラスを内部的に new して、インスタンス化して、perform インスタンスメソッドをCallするための、 perform クラスメソッドを定義します。

というのが大きな役割です。

実際にServiceBase moduleを使ってサービスクラスを実装してみましょう。

class PostCreateService
  include ServiceBase

  private

  attr_reader :title, :content

  def initialize(title:, content:)
    @title = title
    @content = content
  end

  def perform
    Post.new(title: title, content: content).tap do |p|
      p.save
    end
  end
end

次はControllerでサービスクラスをCallしてみましょう。(今回の例ではちょっとサービスクラスの旨味がないですが…)

# ...

def create
  @post = PostCreateService.perform(title: post_params[:title], content: post_params[:content])
  respond_to do |format|
    if @post.valid?
      format.html { redirect_to @post, notice: 'Post was successfully created.' }
      format.json { render :show, status: :created, location: @post }
    else
      format.html { render :new }
      format.json { render json: @post.errors, status: :unprocessable_entity }
    end
  end
end

# ...

暗黙的にクラスメソッドの perform が実装されていて、意図しないクラス名が設定された場合でもエラーになるようになっています。 これでサービスクラスの実装する人も迷わなくて済みそうです。

応用例

今回、サービスクラスに対して一定の制約を設けるためにベースになるModuleを追加しましたが、 このModuleを応用してRailsRunnerだったり、Rakeタスクで処理を行うためのクラスを実装する際にも制約を設けて役に立たたせることが可能です。