Interaction Module
The Interaction module provides a powerful architectural pattern for organizing business logic around user interactions and business actions. It builds upon the traditional MVC pattern by introducing additional layers that encapsulate business logic and improve separation of concerns.
Overview
The Interaction module is located in lib/plutonium/interaction/
and provides:
- Business logic encapsulation separate from controllers
- Consistent handling of success and failure cases
- Flexible and expressive operation chaining
- Integration with ActiveModel for validation
- Response handling for controller actions
- Outcome-based result handling
Key Benefits
- Clear separation of business logic from controllers
- Improved testability of business operations
- Consistent handling of success and failure cases
- Flexible and expressive way to chain operations
- Enhanced maintainability and readability of complex business processes
- Improved code organization and discoverability of business logic
Core Components
Interaction Base (lib/plutonium/interaction/base.rb
)
The foundation for all interactions, integrating with ActiveModel for attributes and validations.
class CreateUserInteraction < Plutonium::Interaction::Base
attribute :first_name, :string
attribute :last_name, :string
attribute :email, :string
validates :first_name, :last_name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
private
def execute
user = User.new(attributes)
if user.save
succeed(user)
.with_redirect_response(user_path(user))
.with_message("User was successfully created.")
else
failed(user.errors)
end
end
end
Key Methods
call(view_context:, **attributes)
- Class method to execute the interactionsucceed(value)
- Create a successful outcome (aliased assuccess
)failed(errors)
- Create a failed outcomeattributes
- Access to all defined attributesvalid?
/invalid?
- ActiveModel validation methods
Outcome (lib/plutonium/interaction/outcome.rb
)
Encapsulates the result of an interaction with success/failure state and optional response.
Success Outcome
# Creating a success outcome
outcome = succeed(user)
.with_message("User created successfully")
.with_redirect_response(user_path(user))
# Checking outcome
outcome.success? # => true
outcome.failure? # => false
outcome.value # => user object
outcome.messages # => [["User created successfully", :notice]]
Failure Outcome
# Creating a failure outcome
outcome = failed(user.errors)
.with_message("Failed to create user", :error)
# Checking outcome
outcome.success? # => false
outcome.failure? # => true
outcome.messages # => [["Failed to create user", :error]]
Outcome Chaining
def execute
CreateUserInteraction.call(view_context: view_context, **user_params)
.and_then { |user| SendWelcomeEmailInteraction.call(view_context: view_context, user: user) }
.and_then { |result| LogUserCreationInteraction.call(view_context: view_context, user: result.value) }
.with_redirect_response(dashboard_path)
.with_message("Welcome! Your account has been created.")
end
Response System (lib/plutonium/interaction/response/
)
Handles controller responses after successful interactions.
Built-in Response Types
Redirect Response
.with_redirect_response(user_path(user))
.with_redirect_response(posts_path, notice: "Post created")
Render Response
.with_render_response(:show, locals: { user: user })
.with_render_response(:edit, status: :unprocessable_entity)
File Response
.with_file_response(file_path, filename: "report.pdf")
Null Response
# Default response when no specific response is set
# Allows controller to handle response manually
Processing Responses in Controllers
class ApplicationController < ActionController::Base
private
def handle_interaction_outcome(outcome)
if outcome.success?
outcome.to_response.process(self) do |value|
# Default response if no specific response is set
render json: { success: true, data: value }
end
else
outcome.messages.each { |msg, type| flash.now[type || :error] = msg }
render json: { errors: outcome.errors }, status: :unprocessable_entity
end
end
end
Nested Attributes (lib/plutonium/interaction/nested_attributes.rb
)
Handle nested resource attributes in interactions.
class CreatePostWithTagsInteraction < Plutonium::Interaction::Base
include Plutonium::Interaction::NestedAttributes
attribute :title, :string
attribute :content, :text
attribute :tags_attributes, :array
validates :title, :content, presence: true
private
def execute
post = Post.new(title: title, content: content)
if post.save
process_nested_attributes(post, :tags, tags_attributes)
succeed(post)
else
failed(post.errors)
end
end
end
Usage Patterns
Basic Interaction
# Define the interaction
class PublishPostInteraction < Plutonium::Interaction::Base
attribute :post_id, :integer
attribute :published_at, :datetime, default: -> { Time.current }
validates :post_id, presence: true
private
def execute
post = Post.find(post_id)
if post.update(published: true, published_at: published_at)
succeed(post)
.with_message("Post published successfully")
.with_redirect_response(post_path(post))
else
failed(post.errors)
end
end
end
# Use in controller
class PostsController < ApplicationController
def publish
outcome = PublishPostInteraction.call(
view_context: view_context,
post_id: params[:id]
)
handle_interaction_outcome(outcome)
end
end
Complex Business Logic
class ProcessOrderInteraction < Plutonium::Interaction::Base
attribute :order_id, :integer
attribute :payment_method, :string
attribute :shipping_address, :string
validates :order_id, :payment_method, :shipping_address, presence: true
private
def execute
order = Order.find(order_id)
# Validate order can be processed
return failed("Order already processed") if order.processed?
return failed("Insufficient inventory") unless check_inventory(order)
# Process payment
payment_result = process_payment(order)
return failed(payment_result.errors) unless payment_result.success?
# Update order
order.update!(
status: 'processing',
payment_method: payment_method,
shipping_address: shipping_address,
processed_at: Time.current
)
# Send notifications
OrderMailer.confirmation_email(order).deliver_later
NotifyWarehouseJob.perform_later(order)
succeed(order)
.with_message("Order processed successfully")
.with_redirect_response(order_path(order))
end
def check_inventory(order)
order.line_items.all? { |item| item.product.stock >= item.quantity }
end
def process_payment(order)
PaymentService.charge(
amount: order.total,
method: payment_method,
order_id: order.id
)
end
end
Interaction Composition
class CompleteUserOnboardingInteraction < Plutonium::Interaction::Base
attribute :user_id, :integer
attribute :profile_data, :hash
attribute :preferences, :hash
private
def execute
user = User.find(user_id)
# Chain multiple interactions
UpdateUserProfileInteraction.call(view_context: view_context, user: user, **profile_data)
.and_then { |result| SetUserPreferencesInteraction.call(view_context: view_context, user: result.value, **preferences) }
.and_then { |result| SendWelcomeEmailInteraction.call(view_context: view_context, user: result.value) }
.and_then { |result| CreateDefaultDashboardInteraction.call(view_context: view_context, user: result.value) }
.with_message("Welcome! Your account setup is complete.")
.with_redirect_response(dashboard_path)
end
end
Integration with Plutonium
Resource Actions
Interactions integrate seamlessly with resource definitions:
class PostDefinition < Plutonium::Resource::Definition
action :publish, interaction: PublishPostInteraction
action :archive, interaction: ArchivePostInteraction
action :feature, interaction: FeaturePostInteraction
end
Controller Integration
Controllers can call interactions directly, but this requires manual setup:
class PostsController < ApplicationController
include Plutonium::Resource::Controller
# Manual controller action (requires custom routing)
def bulk_publish
outcome = BulkPublishPostsInteraction.call(
view_context: view_context,
post_ids: params[:post_ids],
published_at: params[:published_at]
)
# Manual response handling
if outcome.success?
redirect_to posts_path, notice: outcome.messages.first&.first
else
redirect_back(fallback_location: posts_path, alert: "Failed to publish posts")
end
end
end
Note: For automatic integration without manual setup, define actions in resource definitions instead:
class PostDefinition < Plutonium::Resource::Definition
# This automatically handles routing, UI, and response processing
action :bulk_publish, interaction: BulkPublishPostsInteraction
end
Form Integration
class PostDefinition < Plutonium::Resource::Definition
# Form submission automatically uses interactions
action :create, interaction: CreatePostInteraction
action :update, interaction: UpdatePostInteraction
end
Best Practices
Interaction Design
- Single Responsibility: Each interaction should handle one business operation
- Clear Naming: Use descriptive names that indicate the business action
- Validation: Validate inputs using ActiveModel validations
- Error Handling: Return meaningful error messages
- Idempotency: Design interactions to be safely re-runnable when possible
Outcome Handling
- Consistent Responses: Use appropriate response types for different scenarios
- Meaningful Messages: Provide clear success/failure messages
- Proper Chaining: Use
and_then
for sequential operations - Error Propagation: Let failures bubble up through chains
Testing Strategy
- Unit Test Interactions: Test business logic in isolation
- Mock External Services: Use mocks for external dependencies
- Test Both Paths: Cover both success and failure scenarios
- Integration Tests: Test controller integration with system tests
Performance Considerations
- Database Transactions: Use transactions for multi-step operations
- Background Jobs: Move slow operations to background jobs
- Caching: Cache expensive computations when appropriate
- Batch Operations: Use batch processing for bulk operations
Advanced Features
Custom Response Types
class JsonResponse < Plutonium::Interaction::Response::Base
def initialize(data, status: :ok)
super()
@data = data
@status = status
end
private
def execute(controller, &)
controller.render json: @data, status: @status
end
end
# Usage
succeed(user).with_response(JsonResponse.new(user.as_json))
Conditional Execution
class ConditionalInteraction < Plutonium::Interaction::Base
attribute :condition, :boolean
attribute :data, :hash
private
def execute
return succeed(nil) unless condition
# Only execute if condition is true
result = expensive_operation(data)
succeed(result)
end
end
Error Recovery
class ResilientInteraction < Plutonium::Interaction::Base
private
def execute
primary_service_call
.or_else { fallback_service_call }
.or_else { failed("All services unavailable") }
end
def primary_service_call
# Try primary service
result = PrimaryService.call(attributes)
result.success? ? succeed(result.data) : failed(result.errors)
rescue StandardError => e
failed("Primary service error: #{e.message}")
end
def fallback_service_call
# Try fallback service
result = FallbackService.call(attributes)
result.success? ? succeed(result.data) : failed(result.errors)
rescue StandardError => e
failed("Fallback service error: #{e.message}")
end
end
Related Modules
- Resource - Resource definitions and CRUD operations
- Definition - Resource definition DSL
- Core - Base controller functionality
- Action - Custom actions and operations