はじめに
この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第5回です。
第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 | モデルへの表示ロジック混入 |
Workflow + Command パターン
対応PR:
概要
Workflow(Interactor とも呼ばれる)は、トランザクションを跨ぐ複数のステップを
一か所にまとめるパターンです。失敗時のロールバックを保証します。
Command は実行と結果(成功/失敗)を1つのオブジェクトにまとめるパターンです。
例外を Result オブジェクトとして正規化し、コントローラが例外ハンドリングを
書かなくてよいようにします。
main ブランチのコントローラは100行を超えており、バリデーション・クエリ・集計・
保存・通知・監査ログのすべてが1つのアクションに詰め込まれています。
この2つのパターンがそれぞれの役割を分担して、コントローラを劇的にスリム化します。
問題:アンチパターン
main ブランチのコントローラ create アクションは、次のすべてを担当しています。
# app/controllers/weekly_reports_controller.rb (main ブランチ) — 抜粋
def create
# 1. パラメータ取得・型変換
period_start = Date.parse(params[:period_start].to_s) rescue nil
period_end = Date.parse(params[:period_end].to_s) rescue nil
# 2. バリデーション(10行以上)
@errors << "開始日を入力してください" if period_start.nil?
# ...
# 3. クエリ(AR の詳細)
weight_entries = WeightEntry.where(user: current_user)
.where(recorded_on: period_start..period_end)
.order(recorded_on: :asc)
# 4. 集計(10行以上)
avg_weight_g = if weight_entries.any?
(weight_entries.sum(:weight_g) / weight_entries.count.to_f).round
end
# ...
# 5. 保存
@report = WeeklyReport.create!(...)
# 6. 監査ログ(手動)
AuditLog.create!(auditable: @report, ...)
# 7. 通知(同期)
case channel
when "email"
WeeklyReportMailer.report_ready(@report).deliver_now
end
@report.update!(notified_at: Time.current)
redirect_to weekly_report_path(@report), notice: "レポートを生成しました"
rescue ActiveRecord::RecordInvalid => e
@errors = e.record.errors.full_messages
render :new, status: :unprocessable_entity
end
問題点
- 単一責任の原則違反: コントローラが7つの責任を持つ
- トランザクション管理がない: レポート保存後に通知が失敗しても監査ログが残らない
-
例外ハンドリングが分散:
rescueがコントローラに書かれ、呼び出し元が例外を知る必要がある - テストが困難: 全ステップを通じた結合テストしか書けない
解決策1:Workflow の導入
# app/workflows/generate_weekly_report_workflow.rb
class GenerateWeeklyReportWorkflow
def self.call(user:, period_start:, period_end:, channel:)
new(user:, period_start:, period_end:, channel:).call
end
def initialize(user:, period_start:, period_end:, channel:)
@user = user
@period_start = period_start
@period_end = period_end
@channel = channel
end
def call
ActiveRecord::Base.transaction do
weight_entries = WeightEntry.where(user: @user)
.where(recorded_on: @period_start..@period_end)
.order(recorded_on: :asc)
workouts = Workout.where(user: @user)
.where(recorded_on: @period_start..@period_end)
.order(recorded_on: :asc)
avg_weight_g = calc_avg_weight(weight_entries)
avg_body_fat_bp = calc_avg_body_fat(weight_entries)
total_calories_kcal = workouts.sum(:calories_kcal)
total_workout_min = workouts.sum(:duration_min)
report = WeeklyReport.create!(
user: @user,
period_start: @period_start,
period_end: @period_end,
avg_weight_g:,
avg_body_fat_bp:,
total_calories_kcal:,
total_workout_min:
)
AuditLog.create!(
auditable: report,
event: "weekly_report_created",
user_id: @user.id,
payload: { period_start: @period_start, period_end: @period_end, notified_at: nil }
)
case @channel
when "email"
WeeklyReportMailer.report_ready(report).deliver_now
when "slack"
Rails.logger.info "[Slack] Weekly report ready: #{report.id}"
end
report.update!(notified_at: Time.current)
report
end
end
end
ActiveRecord::Base.transaction で全ステップを包むことで、
中間で失敗した場合にロールバックが保証されます。
解決策2:Command の導入
# app/commands/generate_weekly_report_command.rb
class GenerateWeeklyReportCommand
Result = Struct.new(:success?, :report, :errors, keyword_init: true)
def self.call(user:, period_start:, period_end:, channel:)
new(user:, period_start:, period_end:, channel:).call
end
def initialize(user:, period_start:, period_end:, channel:)
@user = user
@period_start = period_start
@period_end = period_end
@channel = channel
end
def call
errors = validate
return Result.new(success?: false, report: nil, errors:) if errors.any?
# ...クエリ・集計・保存・通知(略)...
Result.new(success?: true, report:, errors: nil)
rescue => e
Result.new(success?: false, report: nil, errors: [ e.message ])
end
private
def validate
errors = []
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
errors
end
end
Result Struct は success?, report, errors の3つのフィールドを持ちます。
例外が発生しても rescue でキャッチして Result として返すため、
コントローラは例外ハンドリングを書く必要がありません。
Before / After 比較
コントローラの変化(Command 導入後)
# Before: create アクションが100行超
def create
period_start = Date.parse(params[:period_start].to_s) rescue nil
# ...バリデーション10行...
# ...クエリ10行...
# ...集計15行...
# ...保存...
# ...監査ログ...
# ...通知...
redirect_to ...
rescue ActiveRecord::RecordInvalid => e
@errors = e.record.errors.full_messages
render :new, status: :unprocessable_entity
end
# After: create アクションが15行程度
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
result = GenerateWeeklyReportCommand.call(
user: current_user,
period_start:,
period_end:,
channel:
)
if result.success?
redirect_to weekly_report_path(result.report), notice: "レポートを生成しました"
else
@errors = result.errors
render :new, status: :unprocessable_entity
end
end
トランザクション管理
# Before: トランザクションなし(保存後に通知失敗 → 中途半端な状態)
@report = WeeklyReport.create!(...)
AuditLog.create!(...) # 失敗してもレポートは残る
WeeklyReportMailer.deliver_now # 失敗してもロールバックされない
# After: トランザクション内(いずれかが失敗 → すべてロールバック)
ActiveRecord::Base.transaction do
report = WeeklyReport.create!(...)
AuditLog.create!(...) # 失敗 → report も消える
# ...
end
Result オブジェクトによる正規化
# Before: 例外と条件分岐が混在
rescue ActiveRecord::RecordInvalid => e
@errors = e.record.errors.full_messages
render :new, status: :unprocessable_entity
end
# After: Result で統一的に分岐
result = GenerateWeeklyReportCommand.call(...)
if result.success?
redirect_to weekly_report_path(result.report)
else
@errors = result.errors # バリデーションエラーも例外も同じ形
render :new, status: :unprocessable_entity
end
まとめ
Workflow のメリット
-
ActiveRecord::Base.transactionで複数ステップの原子性を保証 - 失敗時の自動ロールバックでデータ整合性を保護
- ステップの順序と依存関係を一か所に明示
Command のメリット
-
ResultStruct による成功/失敗の統一的な表現 -
rescueでキャッチした例外をResultに変換 → コントローラが例外を知る必要がない - バリデーション失敗も例外もコントローラからは同じ
result.success?で判定できる
2つのパターンの使い分け
| Workflow | Command |
|---|---|
| トランザクション管理 | バリデーション + 結果の返却 |
| 複数ステップの順序保証 | 例外の正規化 |
| ロールバックが必要な処理 | コントローラとドメインの境界 |
実際のプロジェクトでは Command が Workflow を呼び出す構成が多く、
Command はコントローラと Workflow の橋渡し役になります。