今回は1つのフォームで複数のモデルを扱う際に重宝される設計手法「Form Object(フォームオブジェクト)」について簡易実装してみたいと思います。Form Objectはデータベースに保存しないフォームでもActive Recordを使用できるため、可読性の高いコードを実現することができます。
Form Objectとは
Form Objectはデザインパターンの1つで、1つのフォームから複数のリソースを同時保存する際などに役立つ設計手法です。
「1つのフォームから複数リソースを同時保存できる」という言葉を聞くと「accepts_nested_attributes_for」を思い浮かべる人も多いとは思いますが、この「accepts_nested_attributes_for」にはバグが多く、Rails生みの親であるDHH氏が消したいとも公言しています。
そのため、1つのフォームから複数モデルを扱う際は「accepts_nested_attributes_for」ではなく「Form Object」の使用が推奨されています。

Form Objectのメリット
Form Objectを使用するメリットとしては以下の2点です。
- DBに保存しないフォームでも、ActiveRecordのバリデーションが使用可能
- 散在するロジックをForm Object1箇所に集約することができるので管理が容易
Form Objectを使用したアプリケーション開発
ではここからForm Objectを簡易実装してみます。
今回は本の情報と作者情報を同時に保存できるミニアプリを作成します。
新規アプリケーションの立ち上げ
まずは以下のコマンドで新規アプリケーションを作成しましょう。
% rails new formObject-app -d mysql
% cd formObject-app
% rails db:create
今回開発するアプリケーションの環境は以下のようになります。
- アプリケーション名:formObject-app
- Rails:6.0.3.2
- Ruby:2.6.5
- DB:MySQL
Autherモデルの作成
続いて作者情報を管理するモデル「Autherモデル」を作成します。
カラムは「name」と「birth_place」と「birth_date」の3つとします。
% rails g model auther name:string birth_place:string birth_date:date
% rails db:migrate
Bookモデルの作成
次に本の情報を管理するモデル「Bookモデル」を作成します。
モデルの作成には便利なScaffoldの機能を使用し、カラムは「title」と「price」と「publish_date」の3つとします。
% rails g scaffold book title:string price:integer publish_date:date auther:references
% rails db:migrate
アソシエーションの定義
先ほど作成した「Autherモデル」と「Bookモデル」のアソシエーションを1対多の関係で組みましょう。
class Book < ApplicationRecord
belongs_to :auther
end
class Auther < ApplicationRecord
has_many :books
end
ルーティングの作成
現在ではルートパスが未定義のため、以下のようにroutes.rbを定義します。
Rails.application.routes.draw do
root "books#index"
resources :books
end
フォームの作成
現在は本の情報を入力するフォームしか作成されていません。

ここに作者情報も入力することができるフォームを加えていきます。
「app/views/books/new.html.erb」と「app/views/books/_form.html.erb」を以下のように編集します。
<%# app/views/books/new.html.erb %>
<h1>本情報</h1>
<%= render 'form', book: @book %>
<%= link_to '戻る', books_path %>
<%# app/views/books/_form.html.erb %>
<%= form_with(model: @book, url: books_path, local: true) do |form| %>
<% if book.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(book.errors.count, "error") %> prohibited this book from being saved:</h2>
<ul>
<% book.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :タイトル %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :価格 %>
<%= form.number_field :price %>
</div>
<div class="field">
<%= form.label :出版日 %>
<%= form.date_select :publish_date %>
</div>
<br>
<h1>作者情報</h1>
<div class="field">
<%= form.label :名前 %>
<%= form.text_field :name %>
</div>
<div class="field">
<%= form.label :出身地 %>
<%= form.text_field :birth_place %>
</div>
<div class="field">
<%= form.label :生年月日 %>
<%= form.date_select :birth_date %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>

Form Objectの作成
では実際にForm Objectの作成に取り掛かります。
今回は「auther_books.rb」という名前のForm Objectを作成します。
まずは「app/forms」を作成し、その配下に「auther_books.rb」を作成しましょう。

「auther_books.rb」を以下のように編集します。
class AutherBooks
include ActiveModel::Model
include ActiveRecord::AttributeAssignment
attr_accessor :name, :birth_place, :birth_date, :title, :price, :publish_date
with_options presence: true do
validates :name
validates :birth_place
validates :birth_date
validates :title
validates :publish_date
end
validates :price, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 1000000, message: "is out of setting range"}
def save
ActiveRecord::Base.transaction do
auther = Auther.create(name: name, birth_place: birth_place, birth_date: birth_date)
Book.create(title: title, price: price, publish_date: publish_date, auther_id: auther.id)
end
end
end
include ActiveRecord::AttributeAssignment
上記がない場合、UnknownAttributeErrorが発生してしまいます。
フォームでdata_selectを使用した場合、「”publish_date(1i)”=>”2020″, “publish_date(2i)”=>”10”, “publish_date(3i)”=>”8″」のような形でパラメーターが送信されます。本来であれば1つのpublish_dateとしてストロングパラメーターが認識してくれますが、ActiveRecord:Baseを継承していないFormObjectのようなモデルでは認識してくれません。ActiveRecord::AttributeAssignmentという記述がそのようなエラーを回避してくれます。
もう少し言うと、ActiveRecord:Baseを継承したモデルではassign_multiparameter_attributesというメソッドがそういった処理を行なってくれています。

コントローラーの編集
books_controller.rbを以下のように編集しましょう。
class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy]
# GET /books
# GET /books.json
def index
@books = Book.all
end
# GET /books/1
# GET /books/1.json
def show
end
# GET /books/new
def new
@book = AutherBooks.new
end
# GET /books/1/edit
def edit
end
# POST /books
# POST /books.json
def create
@book = AutherBooks.new(book_params)
if @book.valid?
@book.save # バリデーションをクリアした時
return redirect_to root_path
else
render "new" # バリデーションに弾かれた時
end
end
# PATCH/PUT /books/1
# PATCH/PUT /books/1.json
def update
respond_to do |format|
if @book.update(book_params)
format.html { redirect_to @book, notice: 'Book was successfully updated.' }
format.json { render :show, status: :ok, location: @book }
else
format.html { render :edit }
format.json { render json: @book.errors, status: :unprocessable_entity }
end
end
end
# DELETE /books/1
# DELETE /books/1.json
def destroy
@book.destroy
respond_to do |format|
format.html { redirect_to books_url, notice: 'Book was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_book
@book = Book.find(params[:id])
end
# Only allow a list of trusted parameters through.
def book_params
params.require(:auther_books).permit(:name, :birth_place, :birth_date, :title, :price, :publish_date, :auther)
end
end
これでForm Objectを用いてnewとcreateを実装することができました。
挙動確認
参考記事
- メドピア開発者ブログ
- LiBzTechBlog
- MoneyForward Engineers’Blog
- (How) Can I use a form object for the edit/update routines?
今回はデザインパターンの1つであるForm Objectについて簡易実装してみたので紹介しました。ロジックが散在していたり、モデルが肥大化しそうな場合に役立つ設計のため、ぜひ取り入れてみてください。