Fat Controllerをコードで防ぐ方法はないか?」という記事で、まさにFat Controllerを防ぐ私案として、Intent-Responseパターンを提示した。分かりづらいと思うので、もう少し説明してみる。

Fat Controlerの実例

ぼくが思うFat Controlerは、こういうものだ。

  def diff
    journal = Journal::AggregatedJournal.containing_journal(@journal)
    field = params[:field].parameterize.underscore.to_sym

    unless valid_diff?
      return render_404
    end

    unless journal.details[field].is_a?(Array)
      return render_400 message: I18n.t(:error_journal_attribute_not_present, attribute: field)
    end

    from = journal.details[field][0]
    to = journal.details[field][1]

    @diff = Redmine::Helpers::Diff.new(to, from)
    @journable = journal.journable
    respond_to do |format|
      format.html
      format.js do
        render partial: 'diff', locals: { diff: @diff }
      end
    end
  end

OpenProjectというRails製のOSSに含まれているコードである。JournalsControllerのアクションだ。

それほどFatに見えないかもしれないが、ビジネスロジックもプレゼンテーションロジックも含まれていて、コントローラが責務を担いすぎているようにぼくには思える。

たとえば、@journable の扱いがおかしい。JS用のテンプレートでは @journable を参照しない。だから、下記のように、HTMLが要求されたときだけ設定されるべきだ。

    respond_to do |format|
      format.html do
        @journable = journal.journable
        render
      end
      format.js do
        render partial: 'diff', locals: { diff: @diff }
      end
    end

このような混乱が生じるのは、コントローラがプレゼンテーションロジックまで担ってしまっているからだ。

どうしたら防げるだろうか。

Intent

まず、ビジネスロジックについて考えてみよう。

JournalsController#diff のビジネスロジックは「diffの取得」である。HTMLが要求されたときでもJSが要求されたときでもレスポンスに含まれるのはdiffだけだ。

こうしたビジネスロジックを「Intent」と呼ぶことにする。JournalsController#diff にアクセスするユーザの意図、すなわちIntentは「diffの取得」ということである。

Response

一方、プレゼンテーションロジックについてはどうか。

JournalsController#diff のレスポンスは、表現形式(HTML or JS)を無視すれば、3種類考えられる。ステータスコードが404、400、200の場合に対応するものだ。

3種類のレスポンスに必要なロジックはそれぞれ異なる。404の場合は固定のレスポンスボディを返すだろうし、200の場合は表現形式に応じたレスポンスボディを組み立てることになるだろう。

こうしたレスポンスは、そのまま「Response」と呼ぶことにする。Viewと呼ばないのは、たとえば「204 No Content」のようにレスポンスボディのない応答もありえるからだ。Responseの呼ぶのがふさわしいだろう。

Fat Controlerのリファクタリング

ここまででビジネスロジックをIntentに、プレゼンテーションロジックをResponseに、それぞれ任せる方針が見えた。個々のIntentやResponseはオブジェクトとして扱うのが自然だろう。

となると、JournalsController#diff は下記のようにリファクタリングするのがよさそうだ。

  def diff
    status_code, outcome = Journal::Intent::Diff.new.execute(self)
    responses = {
      404: Journal::Response::FieldNotFound,
      400: Journal::Response::AttributeNotPresent,
      200: Journal::Response::Diff
    }
    responses[status_code].new.build(self, status_code, outcome)
  end

こうすれば、コントローラはIntentの実行と、実行結果に応じたResponseの組み立てを制御するだけでよくなる。outcomeはIntentの結果を意味し、200時にはdiffが、400時にはエラーメッセージが含まれる。

(IntentやResponseにコントローラ自身を渡しているのは、渡した先でparamsやrenderなどを呼び出すと想定したからだ。他に良い手があるかもしれない)

Intent-Responseパターンのメリット

Intent-Responseパターンが絶対の正解だとは言わない。が、コントローラの可読性の低さに起因するエンバグは減るだろう。それだけでも、ぼくにはメリットが大きい。