Active Interaction

バリデーションライブラリRubyサービスオブジェクトビジネスロジックRails入力バリデーション

ライブラリ

Active Interaction

概要

Active Interactionは「Compose your business logic clearly and declaratively」として開発されたRuby向けのサービスオブジェクトパターン実装ライブラリです。ビジネスロジックと入力バリデーションを単一のクラス内で宣言的に定義し、複雑なアプリケーションロジックを明確で再利用可能な形で構築できます。ActiveModelベースの堅牢なバリデーション機能と型強制変換により、コントローラーの肥大化を防ぎ、テスタブルで保守しやすいコードを実現。Rails、Sinatra、Padrino等のWebフレームワークとシームレスに統合できる、Ruby開発における標準的なアーキテクチャソリューションです。

詳細

Active Interactionは2025年現在も活発に開発されているライブラリで、ActiveModelの検証機能を基盤とした強力な型システムと入力バリデーションを提供します。各Interactionクラスは単一責任の原則に従い、明確に定義された入力、処理、出力を持つサービスオブジェクトとして機能します。Ruby 2.7+をサポートし、Rails 6.0+との完全な互換性を維持。型安全な入力処理、詳細なエラーハンドリング、トランザクション管理、ネストしたInteraction呼び出しなど、エンタープライズレベルのアプリケーション開発で必要な機能を包括的に提供します。

主な特徴

  • 宣言的なサービスオブジェクト: 入力定義とビジネスロジックを一箇所に集約
  • 強力な型システム: 20以上の組み込み型と自動型変換機能
  • ActiveModelベース: Rails標準のバリデーション機能をフル活用
  • 詳細なエラーハンドリング: 分かりやすいエラーメッセージと多言語対応
  • テスタビリティ: 単体テストが容易な構造とモック対応
  • フレームワーク統合: Rails、Sinatra等との自然な統合

メリット・デメリット

メリット

  • コントローラーとモデルの責任分離による保守性向上
  • 宣言的API設計による学習コストの低さ
  • ActiveModelエコシステムとの完全な互換性
  • 強力な型変換とバリデーション機能
  • Railsコミュニティでの豊富な実績と信頼性
  • 単体テストが書きやすい構造

デメリット

  • 小規模プロジェクトでのオーバーエンジニアリング懸念
  • 複雑なネストした処理でのパフォーマンスオーバーヘッド
  • Ruby/Rails専用(他言語での相互運用性なし)
  • 学習初期のボイラープレート感
  • 大量データ処理時のメモリ使用量増加
  • デバッグ時のスタックトレースの複雑化

参考ページ

書き方の例

インストールと基本セットアップ

# Gemfile
gem 'active_interaction', '~> 5.3'

# Bundle install
bundle install

# Rails アプリケーションの場合
# config/application.rb
require 'active_interaction'

# 基本的な設定(オプション)
ActiveInteraction.configure do |config|
  # エラーメッセージの日本語化
  config.i18n_scope = [:activeinteraction, :errors]
end

基本的なInteraction作成とバリデーション

# app/interactions/user_creation.rb
class UserCreation < ActiveInteraction::Base
  # 入力の定義と型指定
  string :name
  string :email
  integer :age, default: 18
  boolean :is_admin, default: false
  
  # カスタムバリデーション
  validates :name, presence: true, length: { minimum: 2, maximum: 50 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :age, numericality: { greater_than: 0, less_than: 150 }

  # メインの処理ロジック
  def execute
    # バリデーション済みの入力にアクセス
    user = User.new(
      name: name,
      email: email,
      age: age,
      is_admin: is_admin
    )
    
    if user.save
      # 成功時は作成されたオブジェクトを返す
      user
    else
      # 失敗時はエラーを追加
      user.errors.each do |error|
        errors.add(:base, error.full_message)
      end
      
      # nilを返すことでInteractionが失敗扱いになる
      nil
    end
  end
end

# 使用例
result = UserCreation.run(
  name: "田中太郎",
  email: "[email protected]",
  age: 30
)

if result.valid?
  puts "ユーザー作成成功: #{result.result.name}"
else
  puts "エラー: #{result.errors.full_messages.join(', ')}"
end

# より簡潔な使用方法
user = UserCreation.run!(
  name: "鈴木花子",
  email: "[email protected]",
  age: 25
)
puts "作成されたユーザー: #{user.name}"

複雑な型定義とネストしたデータ処理

# app/interactions/order_processing.rb
class OrderProcessing < ActiveInteraction::Base
  # 様々な型の入力定義
  hash :customer do
    string :name
    string :email
    string :phone, default: nil
  end
  
  array :items do
    hash do
      integer :product_id
      integer :quantity
      float :unit_price
      string :notes, default: ""
    end
  end
  
  string :payment_method
  float :discount_rate, default: 0.0
  date :delivery_date, default: -> { Date.current + 3.days }
  
  # カスタムバリデーション
  validates :payment_method, inclusion: { 
    in: %w[credit_card bank_transfer cash_on_delivery] 
  }
  validates :discount_rate, numericality: { 
    greater_than_or_equal_to: 0, 
    less_than_or_equal_to: 1 
  }
  validate :validate_items_availability
  validate :validate_delivery_date

  def execute
    ActiveRecord::Base.transaction do
      # 顧客情報の処理
      customer_record = find_or_create_customer
      
      # 注文レコードの作成
      order = Order.create!(
        customer: customer_record,
        payment_method: payment_method,
        discount_rate: discount_rate,
        delivery_date: delivery_date,
        total_amount: calculate_total_amount
      )
      
      # 注文項目の作成
      create_order_items(order)
      
      # 在庫の更新
      update_inventory
      
      # 通知の送信
      send_confirmation_email(order)
      
      order
    end
  end

  private

  def validate_items_availability
    items.each_with_index do |item, index|
      product = Product.find_by(id: item[:product_id])
      unless product
        errors.add("items[#{index}].product_id", "商品が見つかりません")
        next
      end
      
      unless product.stock >= item[:quantity]
        errors.add("items[#{index}].quantity", "在庫が不足しています")
      end
    end
  end

  def validate_delivery_date
    if delivery_date < Date.current + 1.day
      errors.add(:delivery_date, "配送日は明日以降を指定してください")
    end
  end

  def find_or_create_customer
    Customer.find_or_create_by(email: customer[:email]) do |c|
      c.name = customer[:name]
      c.phone = customer[:phone]
    end
  end

  def calculate_total_amount
    subtotal = items.sum { |item| item[:quantity] * item[:unit_price] }
    subtotal * (1 - discount_rate)
  end

  def create_order_items(order)
    items.each do |item|
      order.order_items.create!(
        product_id: item[:product_id],
        quantity: item[:quantity],
        unit_price: item[:unit_price],
        notes: item[:notes]
      )
    end
  end

  def update_inventory
    items.each do |item|
      product = Product.find(item[:product_id])
      product.decrement!(:stock, item[:quantity])
    end
  end

  def send_confirmation_email(order)
    OrderMailer.confirmation(order).deliver_later
  end
end

# 使用例
order_data = {
  customer: {
    name: "山田太郎",
    email: "[email protected]",
    phone: "090-1234-5678"
  },
  items: [
    {
      product_id: 1,
      quantity: 2,
      unit_price: 1500.0,
      notes: "ギフト包装希望"
    },
    {
      product_id: 3,
      quantity: 1,
      unit_price: 3000.0
    }
  ],
  payment_method: "credit_card",
  discount_rate: 0.1,
  delivery_date: Date.current + 5.days
}

result = OrderProcessing.run(order_data)
if result.valid?
  puts "注文処理完了: #{result.result.id}"
else
  puts "エラー:"
  result.errors.full_messages.each { |msg| puts "  #{msg}" }
end

Rails統合とコントローラー活用

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    result = UserCreation.run(user_params)
    
    if result.valid?
      @user = result.result
      render json: { 
        status: 'success', 
        user: @user,
        message: 'ユーザーが正常に作成されました'
      }, status: :created
    else
      render json: { 
        status: 'error', 
        errors: result.errors.messages 
      }, status: :unprocessable_entity
    end
  end
  
  def update
    result = UserUpdate.run(user_params.merge(id: params[:id]))
    
    if result.valid?
      @user = result.result
      render json: { status: 'success', user: @user }
    else
      render json: { 
        status: 'error', 
        errors: result.errors.messages 
      }, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :age, :is_admin)
  end
end

# app/interactions/user_update.rb
class UserUpdate < ActiveInteraction::Base
  integer :id
  string :name, default: nil
  string :email, default: nil
  integer :age, default: nil
  boolean :is_admin, default: nil
  
  validates :id, presence: true
  validates :name, length: { minimum: 2, maximum: 50 }, allow_nil: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_nil: true
  validates :age, numericality: { greater_than: 0, less_than: 150 }, allow_nil: true
  
  validate :user_exists

  def execute
    update_attributes = inputs.except(:id).compact
    
    if user.update(update_attributes)
      user
    else
      user.errors.each do |error|
        errors.add(error.attribute, error.message)
      end
      nil
    end
  end

  private

  def user
    @user ||= User.find_by(id: id)
  end

  def user_exists
    errors.add(:id, "ユーザーが見つかりません") unless user
  end
end

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    result = OrderProcessing.run(order_params)
    
    if result.valid?
      @order = result.result
      render json: { 
        status: 'success', 
        order: @order.as_json(include: :order_items),
        message: '注文が正常に処理されました'
      }, status: :created
    else
      render json: { 
        status: 'error', 
        errors: result.errors.messages 
      }, status: :unprocessable_entity
    end
  rescue ActiveRecord::RecordInvalid => e
    render json: { 
      status: 'error', 
      message: 'データベースエラーが発生しました',
      details: e.message
    }, status: :internal_server_error
  end

  private

  def order_params
    params.require(:order).permit(
      customer: [:name, :email, :phone],
      items: [:product_id, :quantity, :unit_price, :notes],
      :payment_method, :discount_rate, :delivery_date
    )
  end
end

高度なバリデーションとエラーハンドリング

# app/interactions/file_upload_processing.rb
class FileUploadProcessing < ActiveInteraction::Base
  file :upload_file
  string :category
  integer :user_id
  hash :metadata, default: {}
  
  # カスタムファイル型バリデーション
  validates :category, inclusion: { 
    in: %w[document image video audio] 
  }
  validate :file_type_validation
  validate :file_size_validation
  validate :user_authorization

  def execute
    begin
      # ファイルの一時保存
      temp_path = save_temp_file
      
      # ウイルススキャン(模擬)
      perform_security_scan(temp_path)
      
      # メタデータの抽出
      extracted_metadata = extract_file_metadata(temp_path)
      
      # 最終保存先への移動
      final_path = move_to_final_destination(temp_path)
      
      # データベースレコードの作成
      file_record = create_file_record(final_path, extracted_metadata)
      
      # 非同期処理のキューイング(画像リサイズ等)
      queue_background_processing(file_record)
      
      file_record
      
    rescue SecurityError => e
      errors.add(:upload_file, "セキュリティチェックに失敗しました: #{e.message}")
      nil
    rescue StandardError => e
      errors.add(:base, "ファイル処理中にエラーが発生しました: #{e.message}")
      nil
    ensure
      # 一時ファイルのクリーンアップ
      cleanup_temp_files
    end
  end

  private

  def file_type_validation
    return unless upload_file
    
    allowed_types = {
      'document' => %w[application/pdf text/plain application/msword],
      'image' => %w[image/jpeg image/png image/gif image/webp],
      'video' => %w[video/mp4 video/avi video/quicktime],
      'audio' => %w[audio/mpeg audio/wav audio/flac]
    }
    
    allowed_mime_types = allowed_types[category]
    unless allowed_mime_types.include?(upload_file.content_type)
      errors.add(:upload_file, "このカテゴリでは対応していないファイル形式です")
    end
  end

  def file_size_validation
    return unless upload_file
    
    max_sizes = {
      'document' => 10.megabytes,
      'image' => 5.megabytes,
      'video' => 100.megabytes,
      'audio' => 20.megabytes
    }
    
    max_size = max_sizes[category]
    if upload_file.size > max_size
      errors.add(:upload_file, "ファイルサイズが上限(#{max_size / 1.megabyte}MB)を超えています")
    end
  end

  def user_authorization
    user = User.find_by(id: user_id)
    unless user
      errors.add(:user_id, "ユーザーが見つかりません")
      return
    end
    
    unless user.can_upload_files?
      errors.add(:user_id, "ファイルアップロード権限がありません")
    end
  end

  def save_temp_file
    temp_dir = Rails.root.join('tmp', 'uploads')
    FileUtils.mkdir_p(temp_dir)
    
    temp_filename = "#{SecureRandom.uuid}_#{upload_file.original_filename}"
    temp_path = temp_dir.join(temp_filename)
    
    File.open(temp_path, 'wb') do |file|
      file.write(upload_file.read)
    end
    
    temp_path
  end

  def perform_security_scan(file_path)
    # 模擬的なセキュリティスキャン
    # 実際の実装ではClamAV等を使用
    content = File.read(file_path, 1024) # 最初の1KBを読み取り
    
    # 危険なパターンの検出(例)
    dangerous_patterns = [
      /<script>/i,
      /javascript:/i,
      /vbscript:/i
    ]
    
    dangerous_patterns.each do |pattern|
      if content.match?(pattern)
        raise SecurityError, "危険なコンテンツが検出されました"
      end
    end
  end

  def extract_file_metadata(file_path)
    base_metadata = {
      original_filename: upload_file.original_filename,
      content_type: upload_file.content_type,
      file_size: File.size(file_path),
      uploaded_at: Time.current
    }
    
    # カテゴリ別の追加メタデータ抽出
    case category
    when 'image'
      base_metadata.merge(extract_image_metadata(file_path))
    when 'video'
      base_metadata.merge(extract_video_metadata(file_path))
    else
      base_metadata
    end.merge(metadata)
  end

  def extract_image_metadata(file_path)
    # 画像メタデータの抽出(ExifReaderや画像処理ライブラリを使用)
    {
      width: 1920,      # 実際の実装では画像から取得
      height: 1080,
      format: 'JPEG'
    }
  end

  def extract_video_metadata(file_path)
    # 動画メタデータの抽出(FFMpeg等を使用)
    {
      duration: 120,    # 秒
      resolution: '1920x1080',
      codec: 'H.264'
    }
  end

  def move_to_final_destination(temp_path)
    final_dir = Rails.root.join('storage', category.pluralize, Date.current.strftime('%Y/%m/%d'))
    FileUtils.mkdir_p(final_dir)
    
    final_filename = "#{SecureRandom.uuid}_#{upload_file.original_filename}"
    final_path = final_dir.join(final_filename)
    
    FileUtils.mv(temp_path, final_path)
    final_path
  end

  def create_file_record(file_path, metadata)
    UploadedFile.create!(
      user_id: user_id,
      category: category,
      filename: File.basename(file_path),
      file_path: file_path.relative_path_from(Rails.root).to_s,
      content_type: upload_file.content_type,
      file_size: File.size(file_path),
      metadata: metadata
    )
  end

  def queue_background_processing(file_record)
    case category
    when 'image'
      ImageProcessingJob.perform_later(file_record.id)
    when 'video'
      VideoProcessingJob.perform_later(file_record.id)
    end
  end

  def cleanup_temp_files
    # 一時ファイルのクリーンアップ処理
    temp_files = Dir[Rails.root.join('tmp', 'uploads', '*')]
    temp_files.select { |f| File.mtime(f) < 1.hour.ago }.each do |f|
      File.delete(f) rescue nil
    end
  end
end

# 多段階バリデーションの例
class MultiStageValidation < ActiveInteraction::Base
  string :email
  string :password
  string :invite_code, default: nil
  
  # 段階的バリデーション
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validate :email_uniqueness
  validate :password_strength
  validate :invite_code_validity, if: :invite_required?

  def execute
    # 段階1: 基本バリデーション(自動実行済み)
    
    # 段階2: ビジネスルールバリデーション
    return nil unless validate_business_rules
    
    # 段階3: 外部サービス連携バリデーション
    return nil unless validate_external_services
    
    # すべてのバリデーションが成功した場合の処理
    create_user_account
  end

  private

  def email_uniqueness
    if User.exists?(email: email)
      errors.add(:email, "このメールアドレスは既に使用されています")
    end
  end

  def password_strength
    return if password.blank?
    
    requirements = [
      [password.length >= 8, "8文字以上である必要があります"],
      [password.match?(/[A-Z]/), "大文字を含む必要があります"],
      [password.match?(/[a-z]/), "小文字を含む必要があります"],
      [password.match?(/\d/), "数字を含む必要があります"],
      [password.match?(/[^A-Za-z\d]/), "特殊文字を含む必要があります"]
    ]
    
    requirements.each do |valid, message|
      errors.add(:password, message) unless valid
    end
  end

  def invite_code_validity
    return if invite_code.blank?
    
    invite = InviteCode.find_by(code: invite_code)
    unless invite&.valid?
      errors.add(:invite_code, "無効な招待コードです")
    end
  end

  def invite_required?
    # ビジネスロジックに基づく招待コード要否判定
    Rails.application.config.invite_only_mode
  end

  def validate_business_rules
    # カスタムビジネスルールの検証
    domain = email.split('@').last
    blacklisted_domains = %w[example.com test.com]
    
    if blacklisted_domains.include?(domain)
      errors.add(:email, "このドメインからの登録は受け付けていません")
      return false
    end
    
    true
  end

  def validate_external_services
    # 外部サービスでのメール検証(例:ZeroBounce、EmailValidator等)
    begin
      response = EmailValidationService.validate(email)
      unless response.deliverable?
        errors.add(:email, "配信不可能なメールアドレスです")
        return false
      end
    rescue => e
      # 外部サービスエラーは警告ログに記録して処理続行
      Rails.logger.warn("Email validation service error: #{e.message}")
    end
    
    true
  end

  def create_user_account
    User.create!(
      email: email,
      password: password,
      invite_code_used: invite_code
    )
  end
end

テスト戦略と実践的なテストケース

# spec/interactions/user_creation_spec.rb
RSpec.describe UserCreation, type: :interaction do
  subject(:result) { described_class.run(inputs) }
  
  describe "正常ケース" do
    let(:inputs) do
      {
        name: "田中太郎",
        email: "[email protected]",
        age: 30
      }
    end
    
    it "ユーザーが正常に作成される" do
      expect(result).to be_valid
      expect(result.result).to be_a(User)
      expect(result.result.name).to eq("田中太郎")
      expect(result.result.email).to eq("[email protected]")
      expect(result.result.age).to eq(30)
      expect(result.result.is_admin).to be_falsey
    end
    
    it "データベースにレコードが作成される" do
      expect { result }.to change(User, :count).by(1)
    end
  end
  
  describe "バリデーションエラー" do
    context "名前が空の場合" do
      let(:inputs) { { name: "", email: "[email protected]", age: 25 } }
      
      it "エラーが返される" do
        expect(result).not_to be_valid
        expect(result.errors[:name]).to include("can't be blank")
      end
    end
    
    context "メールアドレスが無効な場合" do
      let(:inputs) { { name: "テスト", email: "invalid-email", age: 25 } }
      
      it "エラーが返される" do
        expect(result).not_to be_valid
        expect(result.errors[:email]).to include("is invalid")
      end
    end
    
    context "年齢が範囲外の場合" do
      let(:inputs) { { name: "テスト", email: "[email protected]", age: 200 } }
      
      it "エラーが返される" do
        expect(result).not_to be_valid
        expect(result.errors[:age]).to include("must be less than 150")
      end
    end
  end
  
  describe "型変換" do
    let(:inputs) do
      {
        name: "テスト",
        email: "[email protected]",
        age: "25",  # 文字列
        is_admin: "true"  # 文字列
      }
    end
    
    it "適切な型に変換される" do
      expect(result).to be_valid
      expect(result.result.age).to be_an(Integer)
      expect(result.result.age).to eq(25)
      expect(result.result.is_admin).to be_a(TrueClass)
    end
  end
end

# spec/interactions/order_processing_spec.rb
RSpec.describe OrderProcessing, type: :interaction do
  let(:product1) { create(:product, id: 1, stock: 10, price: 1500) }
  let(:product2) { create(:product, id: 2, stock: 5, price: 3000) }
  
  let(:valid_inputs) do
    {
      customer: {
        name: "山田太郎",
        email: "[email protected]",
        phone: "090-1234-5678"
      },
      items: [
        {
          product_id: product1.id,
          quantity: 2,
          unit_price: 1500.0
        },
        {
          product_id: product2.id,
          quantity: 1,
          unit_price: 3000.0
        }
      ],
      payment_method: "credit_card",
      discount_rate: 0.1
    }
  end
  
  before do
    product1
    product2
  end
  
  describe "正常処理" do
    subject(:result) { described_class.run(valid_inputs) }
    
    it "注文が正常に作成される" do
      expect(result).to be_valid
      expect(result.result).to be_a(Order)
      expect(result.result.customer.name).to eq("山田太郎")
      expect(result.result.order_items.count).to eq(2)
    end
    
    it "在庫が適切に減少する" do
      expect { result }.to change { product1.reload.stock }.from(10).to(8)
                      .and change { product2.reload.stock }.from(5).to(4)
    end
    
    it "合計金額が正しく計算される" do
      expect(result.result.total_amount).to eq(5400.0)  # (1500*2 + 3000*1) * 0.9
    end
  end
  
  describe "エラーケース" do
    context "在庫不足の場合" do
      before { product1.update!(stock: 1) }
      
      it "在庫不足エラーが返される" do
        result = described_class.run(valid_inputs)
        expect(result).not_to be_valid
        expect(result.errors.messages).to include("items[0].quantity" => ["在庫が不足しています"])
      end
    end
    
    context "無効な商品IDの場合" do
      let(:invalid_inputs) do
        valid_inputs.tap do |inputs|
          inputs[:items][0][:product_id] = 999
        end
      end
      
      it "商品が見つからないエラーが返される" do
        result = described_class.run(invalid_inputs)
        expect(result).not_to be_valid
        expect(result.errors.messages).to include("items[0].product_id" => ["商品が見つかりません"])
      end
    end
  end
  
  describe "トランザクション管理" do
    it "エラー時にロールバックされる" do
      # モックを使用してsave!でエラーを発生させる
      allow_any_instance_of(Order).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(Order.new))
      
      expect {
        described_class.run(valid_inputs)
      }.not_to change(Customer, :count)
        .and not_change(Order, :count)
        .and not_change(OrderItem, :count)
    end
  end
end

# spec/interactions/file_upload_processing_spec.rb
RSpec.describe FileUploadProcessing, type: :interaction do
  let(:user) { create(:user, :can_upload_files) }
  let(:image_file) do
    ActionDispatch::Http::UploadedFile.new(
      tempfile: File.new(Rails.root.join('spec', 'fixtures', 'sample.jpg')),
      filename: 'sample.jpg',
      type: 'image/jpeg'
    )
  end
  
  let(:valid_inputs) do
    {
      upload_file: image_file,
      category: 'image',
      user_id: user.id,
      metadata: { description: 'テスト画像' }
    }
  end
  
  describe "正常処理" do
    subject(:result) { described_class.run(valid_inputs) }
    
    it "ファイルが正常にアップロードされる" do
      expect(result).to be_valid
      expect(result.result).to be_a(UploadedFile)
      expect(result.result.category).to eq('image')
      expect(result.result.user_id).to eq(user.id)
    end
    
    it "ファイルが適切な場所に保存される" do
      result
      expected_path = Rails.root.join('storage', 'images', Date.current.strftime('%Y/%m/%d'))
      expect(Dir.exist?(expected_path)).to be_truthy
    end
  end
  
  describe "バリデーションエラー" do
    context "サポートされていないファイル形式の場合" do
      let(:invalid_file) do
        ActionDispatch::Http::UploadedFile.new(
          tempfile: File.new(Rails.root.join('spec', 'fixtures', 'sample.txt')),
          filename: 'sample.txt',
          type: 'text/plain'
        )
      end
      
      let(:inputs) { valid_inputs.merge(upload_file: invalid_file) }
      
      it "ファイル形式エラーが返される" do
        result = described_class.run(inputs)
        expect(result).not_to be_valid
        expect(result.errors[:upload_file]).to include("このカテゴリでは対応していないファイル形式です")
      end
    end
    
    context "権限のないユーザーの場合" do
      let(:unauthorized_user) { create(:user, :cannot_upload_files) }
      let(:inputs) { valid_inputs.merge(user_id: unauthorized_user.id) }
      
      it "権限エラーが返される" do
        result = described_class.run(inputs)
        expect(result).not_to be_valid
        expect(result.errors[:user_id]).to include("ファイルアップロード権限がありません")
      end
    end
  end
  
  describe "非同期処理" do
    it "画像処理ジョブがキューに追加される" do
      expect(ImageProcessingJob).to receive(:perform_later).with(kind_of(Integer))
      described_class.run(valid_inputs)
    end
  end
end

# Feature spec (統合テスト)
# spec/features/user_management_spec.rb
RSpec.describe "ユーザー管理", type: :feature do
  scenario "新規ユーザー作成" do
    visit new_user_path
    
    fill_in "名前", with: "田中太郎"
    fill_in "メールアドレス", with: "[email protected]"
    fill_in "年齢", with: "30"
    
    click_button "作成"
    
    expect(page).to have_content("ユーザーが正常に作成されました")
    expect(page).to have_content("田中太郎")
  end
  
  scenario "バリデーションエラーの表示" do
    visit new_user_path
    
    fill_in "名前", with: ""
    fill_in "メールアドレス", with: "invalid-email"
    
    click_button "作成"
    
    expect(page).to have_content("名前を入力してください")
    expect(page).to have_content("メールアドレスの形式が正しくありません")
  end
end

このActiveInteractionの包括的なガイドでは、Ruby開発者向けにサービスオブジェクトパターンの実装から高度なバリデーション、Rails統合、テスト戦略まで実践的な内容を網羅しています。コントローラーの肥大化を防ぎ、保守しやすいRubyアプリケーションの構築に役立てることができます。