InteractorパターンとOrganizerパターン - fjordllc/bootcamp GitHub Wiki
Interactorパターンガイド(新人向け)
目次
- Interactorパターンとは
- いつ使うべきか・使わないべきか
- なぜInteractorパターンを使うのか
- interactor gemの基本的な使い方
- 実装例
- Organizerパターン
- テストの書き方
- ベストプラクティス
- よくある質問
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
🤔 判断に迷う場合の指針
以下の質問に答えて判断してください:
- 複雑さ: 複数のモデルや外部システムとの連携が必要か?
- 責任の分離: コントローラーから切り出すことで理解しやすくなるか?
- 再利用性: 他の場所でも同じビジネスロジックが必要になりそうか?
- テスト: 現在のままではテストが書きにくいか?
- エラーハンドリング: 複雑なエラー処理や状態管理が必要か?
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使用時の注意点
-
適切な粒度でInteractorを分割する
# ✅ 良い例:適切な粒度 organize ValidateInput, FindUser, UpdateProfile, SendNotification # ❌ 悪い例:粒度が細かすぎる organize ValidateEmail, ValidatePassword, ValidateName, CheckEmailExists, UpdateUserEmail, UpdateUserPassword
-
contextの依存関係を明確にする
class ValidateOrder include Interactor def call # 前のInteractorからの必要な値をチェック unless context.user && context.items context.fail!(error: 'Missing required context') end end end
-
失敗時の処理を適切に設計する
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: 以下を推奨します:
- 早期バリデーション: 処理開始前に入力を検証
- 適切な例外処理: 予想される例外を適切にキャッチ
- ログ出力: 予期しないエラーはログに記録
- ユーザーフレンドリーなメッセージ: エンドユーザーに適切なエラーメッセージを提供