0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsアプリで学ぶデザインパターン - Validator Object, Form Object (2/7)

0
Last updated at Posted at 2026-03-16

はじめに

この記事は、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

問題点

  1. コントローラが肥大化: バリデーションルールがアクション内に混在
  2. 再利用不可: 同じルールを別のフォームで使うときにコピペが必要
  3. テストが困難: バリデーションだけをテストするのにコントローラ全体が必要
  4. 型変換とバリデーションが混在: 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::Attributesattribute :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対応しない場合
  • 同じバリデーションルールを複数の場所で使い回したい場合
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?