【Rails】FormObject で Controller を綺麗に

こんにちは、Web アプリケーションエンジニアのミツモトです。
普段は TUNAG という、企業やコミュニティを対象としたサービスの開発しています。
今回のブログでは、TUNAGのユーザー登録を実装するときに採用した、Rails の FormObject を取り上げます。

目次

はじめに

ユーザー登録にあたり、ユーザーだけでなく、その付属情報を扱う Model (所属など)も同時に保存する必要がありました。
それらを各々で処理すると、同じような処理を Controller で繰り返し書くことになり、見通しが悪くなります。
Rails の FormObject を採用することで、複数の Model を一緒に保存でき、 Controller の肥大化を防ぐことができました。
この記事では FormObject とその採用例をご紹介させていただきます。

FormObject

単一のフォーム送信で複数の ActiveRecord モデルを更新したい場合に、その永続化ロジックをカプセル化できるデザインパターンです。
ActiveModel::Model というモジュールを include することで利用できます。

メリット

  • ビジネスロジックが Controller に出ないため、各レイヤーの可読性が良くなる
  • 種類の異なる複数 Model を1つの Model として扱える
  • validation がかけられる
  • 渡ってきた parameter を FormObject のクラス内で parse できる
  • DBに依存しないインスタンスでも、Active Recordと同じインターフェースで扱える(通知など)

採用例

採用する前

ユーザーのみを新規作成する場合、Controller は以下になります。


# ユーザーの新規作成フォームを表示
def new
  @user = User.new
end

# ユーザーを作成
def create
  @user = User.new(user_params)

  if @user.valid?
    @user.save
  end
end

扱う Model が増えると…


# ユーザー、所属、住所の新規作成フォームを表示
def new
  @user = User.new
  @department = Department.new
  @address = Address.new
  .
  .
  .
end

# ユーザーを作成
def create
  @user = User.new(user_params)
  @department = Department.new(deparment_params)
  @address = Address.new(address_params)
  .
  .
  .

  if @user.valid? && @department .valid? && @address.valid? ...
    @user.save
    @department.save
    @address.save
    .
    .
    .
  end
end

同じような処理が増えて、Controller が見辛くなります。

採用した後

User, Department, Address…をまとめるため、Form::Registration という FormObject のクラスを作ります。

Controller の処理


# 登録の新規作成フォーム
def new
  @registration = Form::Registration.new
end

# 登録の実行
def create
  @registration = Form::Registration.new(registration_params)

  if @registration.valid?
    @registration.save # User と Department と Address が create される
  end
end

Form::RegistrationのFormObjectクラス


class Form::Registration
  include ActiveModel::Model

  attr_accessor :user,
                :department,
                :address

  validate :validate_user
  validate :validate_department
  validate :validate_address

  def initialize(params: {})
    @user = User.new(params[:user_params])
    @department = Department.new(params[:department_params])
    @address = Address.new(params[:address_params])
  end

  def save
    @user.save
    @department.save
    @address.save
  end
  .
  .
  .

end

まとめてvalidation をかけたり、parameter を FormObject のクラス内で parse できます。
また、各 Model の attributes ではないけど、保存するかどうかの判定に必要な parameter もここで受け取り、 Form::Registeration の validation として追加することができます。

このように FormObject を使うには、Form::Registration で include している ActiveModel::Model が必要です。

ActiveModel::Model

ActiveModel::Model を include することで、自分で定義したクラスを FormObject として Model のように扱うことができます。

中を見ると、


module ActiveModel
  module Model
    extend ActiveSupport::Concern
    include ActiveModel::AttributeAssignment
    include ActiveModel::Validations
    include ActiveModel::Conversion

    included do
      extend ActiveModel::Naming
      extend ActiveModel::Translation
    end
    .
    .
    .
  end
end

ActiveModel 関連のモジュールが include , extend されています。

errors, valid? などのインスタンスメソッドがを扱える ActiveModel::Validations や、
エラーメッセージを翻訳できる ActiveModel::Translation が定義されています。

Model のようにインスタンスメソッドやクラスメソッドを呼ぶことができます。

おわりに

Rails の FormObject を取り上げました。
MVC アーキテクチャだけでは綺麗にコーディングできない部分を、別のレイヤーに切り出すことで、可読性を高めることができます。もし機会があれば、試しに使ってみてください。

最後まで読んでいただき、ありがとうございました。
スタメンではエンジニアを募集しています。興味がある方はぜひご連絡ください。