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アプリで学ぶデザインパターン - Workflow + Command (5/7)

0
Last updated at Posted at 2026-03-16

はじめに

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

問題点

  1. 単一責任の原則違反: コントローラが7つの責任を持つ
  2. トランザクション管理がない: レポート保存後に通知が失敗しても監査ログが残らない
  3. 例外ハンドリングが分散: rescue がコントローラに書かれ、呼び出し元が例外を知る必要がある
  4. テストが困難: 全ステップを通じた結合テストしか書けない

解決策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 のメリット

  • Result Struct による成功/失敗の統一的な表現
  • rescue でキャッチした例外を Result に変換 → コントローラが例外を知る必要がない
  • バリデーション失敗も例外もコントローラからは同じ result.success? で判定できる

2つのパターンの使い分け

Workflow Command
トランザクション管理 バリデーション + 結果の返却
複数ステップの順序保証 例外の正規化
ロールバックが必要な処理 コントローラとドメインの境界

実際のプロジェクトでは Command が Workflow を呼び出す構成が多く、
Command はコントローラと Workflow の橋渡し役になります。

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?