はじめに
この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第2回です。
第1回から読むことをお勧めします。
| 記事 | 何を解決するか |
|---|---|
| 記事1: Value Object | ロジックの分散 |
| 記事2: Validator Object + Form Object | インラインバリデーション |
| 記事3: Query Object + Service Object | インラインクエリ・集計ロジック |
| 記事4: Adapter + ActiveJob + Pub/Sub | 通知のハードコード・同期処理 |
| 記事5: Workflow + Command | ファットコントローラ・トランザクション管理 |
| 記事6: Policy Object + Concern + Callback | 認可・共通スコープ・監査ログ |
| 記事7: Presenter + ViewComponent | モデルへの表示ロジック混入 |
Validator Object + Form Object パターン
対応PR:
概要
Validator Object はバリデーションロジックを専用クラスに切り出すパターンです。
Form Object はフォーム入力を受け取るための専用オブジェクトで、モデルとは独立して
ActiveModel::Model を使いバリデーション・型変換を担います。
2つを組み合わせることで:
- コントローラのインラインバリデーション(10行以上)を削除できる
- バリデーションルールを複数のフォームで再利用できる
- フォームオブジェクトを通じてテストが容易になる
問題:アンチパターン
main ブランチでは、コントローラの create アクション内にインラインでバリデーションが書かれています。
# app/controllers/weekly_reports_controller.rb (main ブランチ)
def create
period_start = Date.parse(params[:period_start].to_s) rescue nil
period_end = Date.parse(params[:period_end].to_s) rescue nil
channel = params[:notification_channel].presence || "email"
@period_start = period_start
@period_end = period_end
@notification_channel = channel
@errors = []
# インライン バリデーション(10行以上)
@errors << "開始日を入力してください" if period_start.nil?
@errors << "終了日を入力してください" if period_end.nil?
if period_start && period_end
@errors << "開始日は終了日より前の日付を指定してください" if period_start > period_end
@errors << "終了日は未来日を指定できません" if period_end > Date.current
@errors << "期間は31日以内にしてください" if (period_end - period_start).to_i >= 31
end
if @errors.any?
render :new, status: :unprocessable_entity
return
end
# ... 続く
end
問題点
- コントローラが肥大化: バリデーションルールがアクション内に混在
- 再利用不可: 同じルールを別のフォームで使うときにコピペが必要
- テストが困難: バリデーションだけをテストするのにコントローラ全体が必要
-
型変換とバリデーションが混在:
Date.parse ... rescue nilが散在
解決策1:Validator Object の導入
# app/validators/safe_period_validator.rb
class SafePeriodValidator < ActiveModel::Validator
MAX_DAYS = 31
def validate(record)
start_date = record.period_start
end_date = record.period_end
return if start_date.blank? || end_date.blank?
if start_date > end_date
record.errors.add(:period_start, "は終了日より前の日付を指定してください")
end
if end_date > Date.current
record.errors.add(:period_end, "は未来日を指定できません")
end
if end_date - start_date >= MAX_DAYS
record.errors.add(:base, "期間は#{MAX_DAYS}日以内にしてください")
end
end
end
ActiveModel::Validator を継承し、validate(record) メソッドを実装するだけです。
MAX_DAYS = 31 のような定数も一か所にまとめられます。
解決策2:Form Object の導入
# app/forms/weekly_report_form.rb
class WeeklyReportForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :period_start, :date
attribute :period_end, :date
attribute :notification_channel, :string, default: "email"
validates :period_start, :period_end, :notification_channel, presence: true
validate :period_dates_are_valid
private
def period_dates_are_valid
return if period_start.blank? || period_end.blank?
if period_start > period_end
errors.add(:period_start, "は終了日より前の日付を指定してください")
end
if period_end > Date.current
errors.add(:period_end, "は未来日を指定できません")
end
if (period_end - period_start).to_i >= 31
errors.add(:base, "期間は31日以内にしてください")
end
end
end
ActiveModel::Attributes の attribute :period_start, :date により、
文字列のパラメータを Date に自動変換します。Date.parse ... rescue nil が不要になります。
Before / After 比較
コントローラのスリム化
# Before: バリデーション・型変換がコントローラに散在(30行以上)
def create
period_start = Date.parse(params[:period_start].to_s) rescue nil
period_end = Date.parse(params[:period_end].to_s) rescue nil
channel = params[:notification_channel].presence || "email"
@errors = []
@errors << "開始日を入力してください" if period_start.nil?
@errors << "終了日を入力してください" if period_end.nil?
# ... 10行以上のバリデーション
end
# After: Form Object に委譲(数行)
def create
@form = WeeklyReportForm.new(form_params)
unless @form.valid?
render :new, status: :unprocessable_entity
return
end
# @form.period_start, @form.period_end, @form.notification_channel が使える
end
private
def form_params
params.permit(:period_start, :period_end, :notification_channel)
end
バリデーターの再利用
# Validator Object は validates_with で複数フォームから使える
class WeeklyReportForm
include ActiveModel::Model
# ...
validates_with SafePeriodValidator # 再利用!
end
class MonthlyReportForm
include ActiveModel::Model
# ...
validates_with SafePeriodValidator # 同じバリデーターを使い回せる
end
型変換の自動化
# Before: 手動でパースし、nil になる可能性がある
period_start = Date.parse(params[:period_start].to_s) rescue nil
# After: ActiveModel::Attributes が自動変換
attribute :period_start, :date
# 不正な文字列は nil になる(rescue 不要)
まとめ
Validator Object のメリット
- バリデーションロジックを単一クラスにまとめて再利用可能にする
-
MAX_DAYSなどのルールを一か所で管理できる - 単体でテスト可能(
SafePeriodValidator.new.validate(record))
Form Object のメリット
- フォーム入力の型変換・バリデーションをモデルから分離
-
ActiveModel::Attributesにより String → Date の変換が宣言的にできる - コントローラがシンプルになり、フォームオブジェクトを単体テストできる
使いどころ
- 複数の属性をまとめてバリデーションしたい場合(期間の整合性チェックなど)
- フォームの入力がモデルの属性と1対1対応しない場合
- 同じバリデーションルールを複数の場所で使い回したい場合