InteractorパターンとOrganizerパターン - fjordllc/bootcamp GitHub Wiki

Interactorパターンガイド(新人向け)

目次

  1. Interactorパターンとは
  2. いつ使うべきか・使わないべきか
  3. なぜInteractorパターンを使うのか
  4. interactor gemの基本的な使い方
  5. 実装例
  6. Organizerパターン
  7. テストの書き方
  8. ベストプラクティス
  9. よくある質問

Interactorパターンとは

Interactorパターンは、アプリケーションのビジネスロジックを専用のクラスに切り出すデザインパターンです。 このプロジェクトでは、interactor gemを使用してInteractorを実装しています。

Interactorは以下の特徴を持ちます:

  • 単一の責任:一つのユースケース(業務処理)に集中
  • 入力と出力:contextオブジェクトを介した明確なI/F
  • 成功/失敗の明確な管理:処理結果の状態管理
  • テストしやすさ:ビジネスロジックの単体テストが容易

いつ使うべきか・使わないべきか

🔥 使うべき場面(Interactorが適している)

  • 複数のモデルにまたがる複雑なビジネスロジック
  • 複数の処理を順次実行する必要がある場合
  • 外部APIとの連携を含む処理
  • データの整合性を保つ必要がある複雑な更新処理
  • エラーハンドリングが重要な業務処理
  • コントローラーが太りがちな複雑な処理
# ✅ Interactorが適している例
# 複数のモデル操作、データ整合性、エラーハンドリングが必要
class TransferLearningProgress
  include Interactor
  
  def call
    ActiveRecord::Base.transaction do
      copy_learning_record
      copy_submissions  
      copy_reviews
      update_user_statistics
      send_notification
    end
  rescue StandardError => e
    context.fail!(error: e.message)
  end
end

🚫 使わないべき場面(通常のメソッドで十分)

  • 単一モデルに対するシンプルなCRUD操作
  • 単純な条件分岐程度のロジック
  • ActiveRecordのコールバックで十分な処理
  • 1つのメソッドで完結する簡単な処理
# ❌ Interactorにする必要がない例
# シンプルすぎるため通常のメソッドで十分
class ActivateUser
  include Interactor
  
  def call
    context.user.update!(active: true)
  end
end

# ✅ 通常のメソッドで十分
def activate_user(user)
  user.update!(active: true)
end

🤔 判断に迷う場合の指針

以下の質問に答えて判断してください:

  1. 複雑さ: 複数のモデルや外部システムとの連携が必要か?
  2. 責任の分離: コントローラーから切り出すことで理解しやすくなるか?
  3. 再利用性: 他の場所でも同じビジネスロジックが必要になりそうか?
  4. テスト: 現在のままではテストが書きにくいか?
  5. エラーハンドリング: 複雑なエラー処理や状態管理が必要か?

3つ以上が「はい」なら Interactor を検討しましょう。

なぜInteractorパターンを使うのか

1. 責任の明確化

  • ビジネスロジックがコントローラーやモデルから分離される
  • 単一責任の原則に従った設計になる
  • 処理の流れが理解しやすくなる

2. テストしやすさの向上

  • ビジネスロジックを独立してテストできる
  • モックやスタブを使いやすい
  • エッジケースのテストがしやすい

3. 再利用性

  • 複数のコントローラーから同じビジネスロジックを呼び出せる
  • バックグラウンドジョブからも利用できる
  • APIとWeb UIで共通のロジックを使える

4. エラーハンドリングの統一

  • 成功/失敗の状態管理が統一される
  • エラーメッセージの管理が容易
  • ロールバック処理が書きやすい

interactor gemの基本的な使い方

Gemfileの設定

gem 'interactor', '~> 3.0'

基本的なInteractorの構造

class YourInteractor
  include Interactor

  def call
    # ここにビジネスロジックを書く
    validate_inputs
    return if context.failure?

    perform_main_logic
  end

  private

  def validate_inputs
    unless context.param1
      context.fail!(error: 'param1 is required')
    end
  end

  def perform_main_logic
    # メインのロジック
    context.result = some_operation(context.param1)
  end
end

使用方法

# Interactorの呼び出し
result = YourInteractor.call(param1: 'value')

if result.success?
  puts result.result
else
  puts result.error
end

contextオブジェクト

  • 入力: Interactorに渡すパラメータ
  • 出力: Interactorが返す結果
  • 状態管理: 成功/失敗の状態
  • 共有: Organizer内でのデータ共有
class ExampleInteractor
  include Interactor

  def call
    # contextから入力を取得
    user = context.user
    amount = context.amount

    # 処理結果をcontextに設定
    context.new_balance = user.balance + amount
    
    # 失敗時の処理
    if amount < 0
      context.fail!(error: 'Amount must be positive')
    end
  end
end

# 使用例
result = ExampleInteractor.call(user: user, amount: 100)
puts result.new_balance if result.success?

実装例

例1: 学習記録のコピー(既存の実装)

class CopyLearning
  include Interactor

  def call
    validate_inputs
    return if context.failure?

    find_original_learning
    return if context.failure?

    check_existing_learning
    return if existing_learning_found?

    create_copied_learning
  end

  private

  def validate_inputs
    return if context.user && context.from_practice && context.to_practice

    context.fail!(error: 'Missing required parameters: user, from_practice, to_practice')
  end

  def find_original_learning
    context.original_learning = Learning.find_by(
      user: context.user,
      practice: context.from_practice
    )

    return if context.original_learning

    context.fail!(error: 'Original learning not found')
  end

  def check_existing_learning
    context.existing_learning = Learning.find_by(
      user: context.user,
      practice: context.to_practice
    )
  end

  def existing_learning_found?
    if context.existing_learning
      context.message = 'Learning already exists, skipping copy'
      true
    else
      false
    end
  end

  def create_copied_learning
    context.copied_learning = Learning.create!(
      user: context.user,
      practice: context.to_practice,
      status: context.original_learning.status
    )

    context.message = 'Learning copied successfully'
  rescue ActiveRecord::RecordInvalid => e
    context.fail!(error: "Failed to create learning: #{e.message}")
  end
end

例2: ユーザー登録処理

class RegisterUser
  include Interactor

  def call
    validate_input
    return if context.failure?

    create_user
    return if context.failure?

    send_welcome_email
    create_initial_data
  end

  private

  def validate_input
    email = context.email
    password = context.password

    if email.blank?
      context.fail!(error: 'Email is required')
      return
    end

    if User.exists?(email: email)
      context.fail!(error: 'Email already exists')
      return
    end

    if password.length < 8
      context.fail!(error: 'Password must be at least 8 characters')
    end
  end

  def create_user
    context.user = User.create!(
      email: context.email,
      password: context.password,
      name: context.name
    )
  rescue ActiveRecord::RecordInvalid => e
    context.fail!(error: "Failed to create user: #{e.message}")
  end

  def send_welcome_email
    UserMailer.welcome(context.user).deliver_later
  rescue StandardError => e
    Rails.logger.error "Failed to send welcome email: #{e.message}"
    # メール送信の失敗はユーザー作成を失敗にしない
  end

  def create_initial_data
    context.user.create_profile!
    context.user.learnings.create!(practice: Practice.first, status: :unstarted)
  rescue StandardError => e
    Rails.logger.error "Failed to create initial data: #{e.message}"
  end
end

Organizerパターン

Organizerパターンは、複数のInteractorを順次実行するためのパターンです。 複雑なビジネスプロセスを小さなInteractorに分割し、それらを組み合わせて実行することで、各処理の責任を明確にし、テストしやすく保守しやすいコードを実現します。

Organizerパターンの利点

  • 関心の分離: 各Interactorが単一の責任を持つ
  • 再利用性: 個別のInteractorを他の文脈でも利用可能
  • テストのしやすさ: 各段階を独立してテストできる
  • 失敗時の制御: 途中で失敗した場合の処理を制御できる
  • ロールバック: 必要に応じて実行済みの処理を取り消せる

Interactor::Organizerの使用

interactor gemにはInteractor::Organizerという便利な機能が用意されています:

class ProcessOrderOrganizer
  include Interactor::Organizer

  # 実行順序を定義
  organize ValidateOrder,
           CalculateShipping,
           ChargePayment,
           CreateOrder,
           SendConfirmationEmail
end

# 使用方法
result = ProcessOrderOrganizer.call(order_params: params)
if result.success?
  redirect_to order_path(result.order)
else
  flash[:error] = result.error
  render :new
end

Interactor::Organizerの特徴:

  • organizeで実行順序を宣言的に定義
  • 途中で失敗すると後続の処理は実行されない
  • rollbackメソッドが定義されていれば、失敗時に逆順で実行される
  • 各InteractorのcontextはOrganizer全体で共有される

ロールバック機能の例:

class ChargePayment
  include Interactor

  def call
    context.payment_id = charge_credit_card(context.amount)
  end

  def rollback
    refund_payment(context.payment_id) if context.payment_id
  end
end

class CreateOrder
  include Interactor

  def call
    context.order = Order.create!(context.order_params)
  end

  def rollback
    context.order.destroy if context.order
  end
end

複雑な制御フローが必要な場合

Organizerでは対応できない複雑な制御フロー(条件分岐、ループ処理、部分的な失敗の許容など)が必要な場合は、単一のInteractorの中で複数の処理を組み合わせることもできます:

class CopyOptionalData
  include Interactor

  def call
    success_count = 0
    total_count = 3

    success_count += 1 if copy_profile.success?
    success_count += 1 if copy_preferences.success?
    success_count += 1 if copy_history.success?

    if success_count == 0
      context.fail!(error: 'All copy operations failed')
    else
      context.success_count = success_count
      context.total_count = total_count
      context.message = "#{success_count}/#{total_count} operations succeeded"
    end
  end

  private

  def copy_profile
    CopyProfile.call(context.to_h)
  rescue StandardError => e
    Rails.logger.warn "Profile copy failed: #{e.message}"
    OpenStruct.new(success?: false)
  end

  def copy_preferences
    CopyPreferences.call(context.to_h)
  rescue StandardError => e
    Rails.logger.warn "Preferences copy failed: #{e.message}"
    OpenStruct.new(success?: false)
  end

  def copy_history
    CopyHistory.call(context.to_h)
  rescue StandardError => e
    Rails.logger.warn "History copy failed: #{e.message}"
    OpenStruct.new(success?: false)
  end
end

Organizer使用時の注意点

  1. 適切な粒度でInteractorを分割する

    # ✅ 良い例:適切な粒度
    organize ValidateInput,
             FindUser,
             UpdateProfile,
             SendNotification
    
    # ❌ 悪い例:粒度が細かすぎる
    organize ValidateEmail,
             ValidatePassword,
             ValidateName,
             CheckEmailExists,
             UpdateUserEmail,
             UpdateUserPassword
    
  2. contextの依存関係を明確にする

    class ValidateOrder
      include Interactor
    
      def call
        # 前のInteractorからの必要な値をチェック
        unless context.user && context.items
          context.fail!(error: 'Missing required context')
        end
      end
    end
    
  3. 失敗時の処理を適切に設計する

    class ProcessPayment
      include Interactor
    
      def call
        # 支払い処理
        context.payment = create_payment
      end
    
      def rollback
        # 支払いをキャンセル
        cancel_payment(context.payment) if context.payment
      end
    end
    

テストの書き方

基本的なテスト構造

require 'test_helper'

class CopyLearningTest < ActiveSupport::TestCase
  test "should copy learning successfully" do
    # Given
    user = users(:student)
    from_practice = practices(:ruby_basic)
    to_practice = practices(:ruby_advanced)
    original_learning = learnings(:ruby_basic_completed)

    # When
    result = CopyLearning.call(
      user: user,
      from_practice: from_practice,
      to_practice: to_practice
    )

    # Then
    assert result.success?
    assert_equal 'Learning copied successfully', result.message
    assert_not_nil result.copied_learning
    assert_equal to_practice, result.copied_learning.practice
    assert_equal original_learning.status, result.copied_learning.status
  end

  test "should fail when required parameters are missing" do
    result = CopyLearning.call(user: users(:student))

    assert result.failure?
    assert_equal 'Missing required parameters: user, from_practice, to_practice', result.error
  end

  test "should fail when original learning not found" do
    user = users(:student)
    from_practice = practices(:ruby_basic)
    to_practice = practices(:ruby_advanced)

    result = CopyLearning.call(
      user: user,
      from_practice: from_practice,
      to_practice: to_practice
    )

    assert result.failure?
    assert_equal 'Original learning not found', result.error
  end

  test "should skip when learning already exists" do
    user = users(:student)
    from_practice = practices(:ruby_basic)
    to_practice = practices(:ruby_advanced)
    
    # 既存の学習記録を作成
    existing_learning = Learning.create!(
      user: user,
      practice: to_practice,
      status: :complete
    )

    result = CopyLearning.call(
      user: user,
      from_practice: from_practice,
      to_practice: to_practice
    )

    assert result.success?
    assert_equal 'Learning already exists, skipping copy', result.message
  end
end

Organizerのテスト

class CopyPracticeProgressTest < ActiveSupport::TestCase
  test "should copy all progress successfully" do
    user = users(:student)
    from_practice = practices(:ruby_basic)
    to_practice = practices(:ruby_advanced)

    # 元データを準備
    create_original_data(user, from_practice)

    result = CopyPracticeProgress.call(
      user: user,
      from_practice: from_practice,
      to_practice: to_practice
    )

    assert result.success?
    assert_not_nil result.copied_learning
    assert_not_nil result.copied_product
    assert_not_nil result.copied_check
  end

  test "should rollback all changes on failure" do
    user = users(:student)
    from_practice = practices(:ruby_basic)
    to_practice = practices(:ruby_advanced)

    # CopyProductが失敗するように設定
    CopyProduct.any_instance.stubs(:create_copied_product).raises(ActiveRecord::RecordInvalid)

    result = CopyPracticeProgress.call(
      user: user,
      from_practice: from_practice,
      to_practice: to_practice
    )

    assert result.failure?
    # トランザクションによりロールバックされることを確認
    assert_nil Learning.find_by(user: user, practice: to_practice)
  end

  private

  def create_original_data(user, practice)
    learning = Learning.create!(user: user, practice: practice, status: :complete)
    product = Product.create!(user: user, practice: practice, body: 'original content')
    Check.create!(user: user, product: product, status: :passed)
  end
end

モックとスタブを使ったテスト

test "should handle email sending failure gracefully" do
  UserMailer.stubs(:welcome).raises(Net::SMTPError, 'SMTP Error')

  result = RegisterUser.call(
    email: '[email protected]',
    password: 'password123',
    name: 'Test User'
  )

  # ユーザー作成は成功するが、メール送信は失敗
  assert result.success?
  assert_not_nil result.user
  # ログにエラーが記録されることを確認
  assert_logged "Failed to send welcome email: SMTP Error"
end

ベストプラクティス

1. 早期リターンによる可読性向上

class ExampleInteractor
  include Interactor

  def call
    validate_inputs
    return if context.failure?

    find_user
    return if context.failure?

    perform_operation
  end

  private

  def validate_inputs
    context.fail!(error: 'Invalid input') unless valid_input?
  end
end

3. 適切なエラーハンドリング

class SafeInteractor
  include Interactor

  def call
    ActiveRecord::Base.transaction do
      risky_operation
    end
  rescue ActiveRecord::RecordInvalid => e
    context.fail!(error: "Validation failed: #{e.message}")
  rescue ActiveRecord::RecordNotFound => e
    context.fail!(error: "Record not found: #{e.message}")
  rescue StandardError => e
    Rails.logger.error "Unexpected error in #{self.class}: #{e.message}"
    context.fail!(error: "An unexpected error occurred")
  end
end

4. contextの適切な使用

class GoodInteractor
  include Interactor

  def call
    # 入力の検証
    user = context.user
    amount = context.amount

    validate_amount(amount)
    return if context.failure?

    # 処理結果をcontextに設定
    context.new_balance = calculate_new_balance(user, amount)
    context.transaction_id = generate_transaction_id
    
    # 成功メッセージ
    context.message = "Successfully processed #{amount} for #{user.name}"
  end
end

5. 適切なファイル配置

app/
  interactors/
    ├── users/
    │   ├── create_user_account.rb
    │   ├── update_user_profile.rb
    │   └── deactivate_user.rb
    ├── orders/
    │   ├── process_order_organizer.rb
    │   ├── cancel_order_interactor.rb
    │   └── refund_order_interactor.rb
    └── copy_practice_progress.rb

よくある質問

Q: いつInteractorを使うべきですか?

A: 必ず「いつ使うべきか・使わないべきか」の章を参照してください。簡潔に言うと:

  • 複数のモデルにまたがる複雑なビジネスロジック
  • 複数の処理を組み合わせる必要がある場合
  • エラーハンドリングが重要な業務処理
  • コントローラーが太りがちな処理

Q: シンプルな処理でもInteractorにすべきですか?

A: いいえ。単純なCRUD操作や一つのメソッドで完結する処理は通常のメソッドで十分です。

Q: ServiceObjectとInteractorの違いは何ですか?

A: 主な違いは:

  • Interactor: contextによる統一されたI/F、成功/失敗の状態管理
  • ServiceObject: より自由な設計、プロジェクト固有の実装

このプロジェクトではInteractorパターンを採用しており、統一されたAPIとエラーハンドリングの恩恵を受けています。

Q: Organizerパターンはいつ使うべきですか?

A: 以下の場合にOrganizerパターンを検討してください:

  • 複数の独立したInteractorを順次実行する必要がある
  • 途中で失敗した場合に後続の処理を停止したい
  • ロールバック機能が必要な場合

Q: contextに何を設定すべきですか?

A: 以下を設定することを推奨します:

  • 入力パラメータ: Interactorが必要とするデータ
  • 処理結果: 作成されたオブジェクトや計算結果
  • 状態情報: 処理の成功/失敗、メッセージ
  • 共有データ: Organizer内で複数のInteractorが使用するデータ

Q: エラーハンドリングのベストプラクティスは?

A: 以下を推奨します:

  • 早期バリデーション: 処理開始前に入力を検証
  • 適切な例外処理: 予想される例外を適切にキャッチ
  • ログ出力: 予期しないエラーはログに記録
  • ユーザーフレンドリーなメッセージ: エンドユーザーに適切なエラーメッセージを提供