ruby-on-rails
Development

[Rails] モデルのデフォルト値セットをService Objectにしてみた

RailsのようなMVCアーキテクチャのフレームワークではControllerに処理を書くことを極力避けることでFat Controllerにならないようにするのはもう常識と言えるまでに浸透してますよね。

しかし最近はModelに何でもかんでも詰め込みすぎてFat Modelにならないようにしようという動きも有ります。
僕は最初、あぁそれでConcernに分けていくのねと思ったのですがConcernだけでかたづく問題ばかりではありません。

詳しくは
ここ 7 Patterns to Refactor Fat ActiveRecord Models
またはここあたり Object Oriented Rails – Writing better controllers
を読むのがいいようですが、そのなかにService Objectというのがあります。

今回そのService Obejctでモデルのデフォルト値をセットする処理を書いてみましたので紹介します。

まずTask管理をするRailsアプリがあったとしますTasksController#newでこんなコードになってました。

class TasksController
  def new
    @task = Task.new
    @task.category = category if params[:category_name].present? && (category = Category.find_by_name(params[:cateogry_name]).present?
    @task.due_date = params[:due_date].present? ? params[:due_date] : Date.tomorrow
  end
end

ごちゃごちゃしていて分かりづらいですね。

いきなり結果ですが、前述のブログを参考に以下のように変えてみました

class TasksController
  before_action :load_task_default_value_service, only: [:new]

  def new
    @task = Task.new
  @task = @task_default_value_service.set(@task, params)
  end

  private
    def load_task_default_value_service(service = TaskDefaultValueService.new)
      @task_default_value_service ||= service
    end
end

大分スッキリしました。

で肝心のService Obejctは app/services ディレクトリを作って配置します。このとき config/application.rb で config.autoload_path に app/services を追加しておかなければいけません。

app/services/task_default_value_service.rb

class TaskDefaultValueService
  def set(task, params)
    @params = params
    task.category = @cateogry if valid_category_name?
    task.due_date = due_date
  end

  def valid_category_name?
    @category = Category.find_by_name(params[:cateogry_name])
    @params[:category_name].present? && @category.present?
  end

  def due_date
    @params[:due_date].present? ? @params[:due_date] : Date.today
  end
end

たったこれだけの処理が専用のクラスに切り分けることでかなり読みやすくなりますね。

ここまでやって気になったんですがControllerからService Objectを呼び出しておく部分って冗長になりそうだなとおもったんです。
例えば今回だと before_action で load_task_default_value_service を実行するようにしてますが、これってService Objectの名称が変わるだけで同じ処理をたくさん書くはめになりそうです。

なので ApplicationController で method_missing を使ってService Objectの呼び出しを簡単に出来るようにしてみました。

class ApplicationController
  def method_missing(method_name, *arg)
    if method_name =~ /^load_([a-z_]+)_service$/
      load_service_object($1)
    else
      super
    end
  end

  private
    def load_service_object(service_name)
      service_snakecase = (service_name + '_service')
      service = service_snakecase.camelize.constantize
      instance_variable_set("@#{ service_snakecase }", service.new)
    end
end

こうしておけば UserAuthenticationService を使いたい時は

  before_action :load_user_authentication_service

と書くだけでいいので簡単ですね!

標準