Active Interaction
ライブラリ
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アプリケーションの構築に役立てることができます。