虎の穴ラボ技術ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Fat Controller をなんとかしたい フォームオブジェクトでクリーンな Rails アプリ開発

こんにちは、虎の穴ラボの awamo です。

息の長いプロジェクトに携わっていると、当初はシンプルだったはずのコントローラーが、改修を重ねるうちに複雑怪奇に肥大化している…そんな場面に出会うことがあります。

気づけばたった一つのフォームの裏側で膨大な条件分岐が書き連ねられていて、絶望的な気持ちになった経験、あなたにもありませんか?

今回は、そんな状況を改善する「フォームオブジェクトパターン」をご紹介します。

フォームオブジェクト自体は目新しいパターンではありませんし、解説記事も多く存在します。
なのでこの記事では、私が実際のプロジェクトでどういった時にフォームオブジェクトを利用したいと感じるのかを紹介できればと考えています。

そもそもフォームオブジェクトって何?

理屈はさておき、私にとってフォームオブジェクトは「コントローラーの if 文を消し去るための手段」です。

特に、1 つのモデルに複数の意味合いがある場合に使いたくなります。例えば、User モデルに「ファン」と「クリエイター」という 2 つの役割があるケースを想像してみてください。
登録フォームから送られてくるデータは同じ users#create アクションに届きますが、役割によってバリデーションも、場合によっては保存先のテーブルも異なりますよね。複数モデルが関わっていたら尚更です。

こんな時、フォームオブジェクトを使わないと、コントローラーは if params[:user][:is_creator] のような条件分岐で溢れかえってしまいます。
クリエイターで、かつこの設定が有効で、このタイミングだから...なんて状態です。
この分岐は、この場合ファン側であれば気にする必要がないはずですが、コントローラーやアクションが同一であれば見ざるを得ません。

フォームオブジェクトは、そういった「役割ごとのごちゃごちゃした処理」を、それぞれ独立した専用のクラスに閉じ込めてくれる、頼もしいパターンなのだと考えています。

なぜ、そこまでしてコントローラーを綺麗にしたいのか

一つのフォームが複数の役割を持つ場合、フォームオブジェクトは特に強力な力を発揮します。

先ほどの「ファン」と「クリエイター」の登録の例で考えてみましょう。もしフォームオブジェクトを使わないと、コントローラーはこんな風になる場合があります。

# Fat Controllerの例
def create
  user = User.new(user_prams)
  if params[:user][:is_creator]
    # 当初書かれたクリエイター用の処理
  end

  # 後付けで増えたクリエイターの時しか渡されない条件とメソッド
  user.xx_create! if user_params[xx_flag] == 'true'
  user.oo_create! if user_params[oo_valid] == 'true'

  # 後付けで増えたファンの時しか動かない条件とメソッド
  user.aa_create! if user_params[aa_flag] == 'true'

  if user.save
    render xxxx_path
  else
    redirect :new
  end

  # ...
end

このコードの何が問題なのでしょうか?
それは、コントローラーが「どうやって登録するか」という詳細を知りすぎている点です。コントローラーの本来の責務は、「リクエストを受け取り、適切な処理担当に仕事を振り、レスポンスを返す」ことだけのはずだと思います。

私が目指すのは、コントローラーをできるだけ薄くすることです。

理想を言えば、役割が違うなら、いっそコントローラー自体を FansControllerCreatorsController のように分けてしまいたいです。その方がルーティングも明確になり、コードの見通しが格段に良くなります。

RESTful な原則に基づいたシンプルなルーティング設計である、世にいう DHH 流のルーティングというやつが好みなのかもしれません。

フォームオブジェクトは、その理想に近づくための現実的な第一歩だと思います。 細かな条件による分岐をそれぞれのフォームオブジェクトに預けることで、コントローラーをシンプルな姿に戻すことができます。

フォームオブジェクトを導入する主なメリット

フォームオブジェクトを使うことで得られるメリットは以下の 3 つです。

1. コントローラーの責務を明確化できる

フォームオブジェクトにバリデーションやデータ保存といった実務ロジックを委譲することで、コントローラーは「リクエストを受け取り、適切なオブジェクトに処理を渡し、レスポンスを返す」という本来の責務に集中できます。責務が単一になることで、コードの見通しが良くなります。

2. 条件分岐ロジックをクラスとして分離できる

ユーザーの「クリエイター登録」と「ファン登録」のように、1 つのアクション内に複数のシナリオが存在する場合、if文による分岐はコントローラーを複雑化させます。

これらをCreatorRegistrationFormFanRegistrationFormといったクラスに分離することで、それぞれのシナリオに閉じたロジックを管理できます。クラス名自体がその役割を示すため、仕様の把握も容易になります。

3. ビジネスロジックのテストが容易になる

複雑なフォームのロジックをテストする際、コントローラーでテストをしようとすると、リクエストをテストしているのか、ロジックをテストしているのか、対象が絞れなくなっていきます。

フォームオブジェクトは責務としてはデータを受け取り登録するクラスですから、どんなデータを受け取ったらどんなデータがどうできるのかに絞ってテストをすることができます。
Request Spec でこれを行おうとするとテストの見通しも悪くなってしまうので、どちらかというとフォームオブジェクトの方が肥大化したコントローラーのテストよりも容易だと言えると思います。

実践:リファクタリングの具体例

Before (リファクタリング前)

下記のコントローラーでは、createアクション内にクリエイター登録とファン登録のロジックが混在しています。これにより、コントローラーの責務が肥大化し、可読性と保守性を損なっています。

# Fat Controllerの例
def create
  user = User.new(user_prams)
  if params[:user][:is_creator]
    # 当初書かれたクリエイター用の処理
  end

  # 後付けで増えたクリエイターの時しか渡されない条件とメソッド
  user.xx_create! if user_params[xx_flag] == 'true'
  user.oo_create! if user_params[oo_valid] == 'true'

  # 後付けで増えたファンの時しか動かない条件とメソッド
  user.aa_create! if user_params[aa_flag] == 'true'

  if user.save
    render xxxx_path
  else
    redirect :new
  end

  # ...
end

After (リファクタリング後)

フォームオブジェクトを導入し、ロジックをコントローラーから分離します。

コントローラーは、パラメータに応じて適切なフォームオブジェクトを生成し、save メソッドを呼び出す責務のみを持ちます。
フォーム固有のロジックは各フォームオブジェクトクラスにカプセル化され、コントローラーはシンプルに保たれます。

class UsersController < ApplicationController
    def create # 役割に応じて適切なフォームオブジェクトをインスタンス化
        if params[:user][:is_creator] == '1'
            @form = CreatorRegistrationForm.new(creator_params)
        else
            @form = FanRegistrationForm.new(fan_params)
        end

        if @form.save
            redirect_to root_path, notice: '登録が完了しました!'
        else
            render :new
        end
    end

    # ...
end

フォームオブジェクトでは、共通の BaseForm を用意して、それを継承する形でそれぞれに固有の動作を定義していきます。
今回のケースでは、バリデーションとデータの登録を主な責務とするため、 ActiveModel::Model と、 ActiveModel::Attributesinclude しています。
ActiveModel::API だけでも目的の実現はできるので、includeする対象は好みの分かれるところなのかな、と思います。

フォーム側にも attribute の定義をしたり、あくまでもモデル的なオブジェクトとして扱う目的のフォームなので、この2つになっている形です。

class BaseForm
  include ActiveModel::Model
  include ActiveModel::Attributes
end
class CreatorRegistrationForm < BaseForm
  attribute :name, :string
  # 以下 Creator 固有の属性

  validates :name, presence: true
  # 以下 Creator 固有のバリデーション

  def save
    # 複数クラスにまたがる Creator の登録処理
  end
end
class FanRegistrationForm < BaseForm
  attribute :name, :string
  # 以下 Fan 固有の属性

  validates :name, presence: true
  # 以下 Fan 固有のバリデーション

  def save
    # 複数クラスにまたがる Fan の登録処理
  end
end

まとめ

フォームオブジェクトは、複雑化したコントローラーを責務の明確な状態にリファクタリングするための、実践的で強力なパターンだと思います。

特に、今回例示したような「1 つのモデルが複数の役割を持つ」ケースにおいて、コントローラー内の条件分岐をクラスとして分離し、見通しを良くする効果は大きいと思います。

コントローラーを薄く保ち、ビジネスロジックを適切なオブジェクトに集めることは、アプリケーション全体の保守性を高める上で重要だと思います。そのための有効な選択肢として、フォームオブジェクトも悪くない選択肢ではないでしょうか。

テストを増やし、リファクタを進め、保守性の高いコードを増やしていきたいですね。

Fantia 開発採用情報

虎の穴ラボでは現在、一緒に Fantia を開発していく仲間を積極募集中です!
多くのユーザーに使っていただける toC サービスの開発をやってみたい方は、ぜひ弊社の採用情報をご覧ください。
toranoana-lab.co.jp