ActiveInteractor
Ruby interactors with ActiveModel::Validations based on the interactors gem.
Getting Started
Add this line to your application's Gemfile:
gem 'activeinteractor'
And then execute:
bundle
Or install it yourself as:
gem install activeinteractor
If you're working with a rails project you will also want to run:
rails generate active_interactor:install
This will create an initializer and a new class called ApplicationInteractor
at app/interactors/application_interactor.rb
you can then automatically generate interactors and interactor organizers with:
rails generate interactor MyInteractor
rails generate interactor:organizer MyInteractor1 MyInteractor2
These two generators will automatically create an interactor class which
inherits from ApplicationInteractor and a matching spec or test file.
What is an Interactor
An interactor is a simple, single-purpose service object.
Interactors can be used to reduce the responsibility of your controllers, workers, and models and encapsulate your application's business logic. Each interactor represents one thing that your application does.
Usage
Context
Each interactor will have it's own immutable context and context class.
For example:
class MyInteractor < ActiveInteractor::Base
end
MyInteractor.context_class #=> MyInteractor::Context
An interactor's context contains everything the interactor needs to do its work. When an interactor does its single purpose, it affects its given context.
Adding to the Context
All instances of context inherit from OpenStruct. As an interactor runs it can
add information to it's context.
context.user = user
Failing the Context
When something goes wrong in your interactor, you can flag the context as failed.
context.fail!
When given a hash argument or an instance of ActiveModel::Errors, the fail!
method can also update the context. The following are equivalent:
context.errors.merge!(user.errors)
context.fail!
context.fail!(user.errors)
You can ask a context if it's a failure:
context.failure? #=> false
context.fail!
context.failure? #=> true
or if it's a success:
context.success? # => true
context.fail!
context.success? # => false
Dealing with Failure
context.fail! always throws an exception of type ActiveInteractor::Context::Failure.
Normally, however, these exceptions are not seen. In the recommended usage, the consuming
object invokes the interactor using the class method call, then checks the success? method of
the context.
This works because the call class method swallows exceptions. When unit testing an interactor, if calling
custom business logic methods directly and bypassing call, be aware that fail! will generate such exceptions.
See Using Interactors, below, for the recommended usage of perform and success?.
Context Attributes
Each context instance have basic attribute assignment methods which can be invoked directly
from the interactor. You never need to directly interface with an interactor's context class.
Assigning attributes to a context is a simple way to explicitly defined what properties a
context should have after an interactor has done it's work.
You can see what attributes are defined on a given context with the #attributes method:
class MyInteractor < ActiveInteractor::Base
# we define user as an attribute because it will be assigned a value
# in the perform method.
context_attributes :first_name, :last_name, :email, :user
end
context = MyInteractor.perform(
first_name: 'Aaron',
last_name: 'Allen',
email: '[email protected]',
occupation: 'Software Dude'
)
#=> <#<MyInteractor::Context first_name='Aaron', last_name='Allen, email='[email protected]', occupation='Software Dude'>
context.attributes #=> { first_name: 'Aaron', last_name: 'Allen', email: '[email protected]' }
context.occupation #=> 'Software Dude'
You can see what properties are defined on a given context with the #keys method
regardless of whether or not the properties are defined in a context#attributes:
context.keys #=> [:first_name, :last_name, :email, :occupation]
Finally you can invoke #clean! on a context to remove any properties not explicitly
defined in a context#attributes:
context.clean! #=> { occupation: 'Software Dude' }
context.occupation #=> nil
Validating the Context
ActiveInteractor delegates all the validation methods provided by ActiveModel::Validations
onto an interactor's context class from the interactor itself. All of the methods found in
ActiveModel::Validations can be invoked directly on your interactor with the prefix context_.
ActiveInteractor provides two validation callback steps:
:callingused before#performis invoked:calledused after#performis invoked
A basic implementation might look like this:
class MyInteractor < ActiveInteractor::Base
context_attributes :first_name, :last_name, :email, :user
# only validates presence before perform is invoked
context_validates :first_name, presence: true, on: :calling
# validates before and after perform is invoked
context_validates :email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
# validates after perform is invoked
context_validates :user, presence: true, on: :called
context_validate :user_is_a_user, on: :called
def perform
context.user = User.create_with(
first_name: context.first_name,
last_name: context.last_name
).find_or_create_by(email: context.email)
end
private
def user_is_a_user
return if context.user.is_a?(User)
context.errors.add(:user, :invalid)
end
end
context = MyInteractor.perform(last_name: 'Allen')
#=> <#MyInteractor::Context last_name='Allen>
context.failure? #=> true
context.valid? #=> false
context.errors[:first_name] #=> ['can not be blank']
context = MyInterator.perform(first_name: 'Aaron', email: '[email protected]')
#=> <#MyInteractor::Context first_name='Aaron', email='[email protected]'>
context.success? #=> true
context.valid? #=> true
context.errors.empty? #=> true
Callbacks
ActiveInteractor uses ActiveModel::Callbacks and ActiveModel::Validations::Callbacks
on context validation, perform, and rollback. Callbacks can be defined with a block,
Proc, or Symbol method name and take the same conditional arguments outlined
in those two modules.
NOTE: When using symbolized method names as arguments the context class will first attempt to invoke the method on itself, if it cannot find the defined method it will attempt to invoke it on the interactor. Be concious of scope when defining these methods.
Validation Callbacks
We can do work before an interactor's context is validated with the before_context_validation method:
class MyInteractor < ActiveInteractor::Base
context_attributes :first_name, :last_name, :email, :user
context_validates :last_name, presence: true
before_context_validation { last_name ||= 'Unknown' }
end
context = MyInteractor.perform(first_name: 'Aaron', email: '[email protected]')
context.valid? #=> true
context.last_name #=> 'Unknown'
We can do work after an interactor's context is validated with the after_context_validation method:
class MyInteractor < ActiveInteractor::Base
context_attributes :first_name, :last_name, :email, :user
context_validates :email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
after_context_validation :downcase_email!
private
def downcase_email
context.email = context.email&.downcase!
end
end
context = MyInteractor.perform(first_name: 'Aaron', email: '[email protected]')
context.email #=> '[email protected]'
We can prevent a context from failing when invalid by invoking the
allow_context_to_be_invalid class method:
class MyInteractor < ActiveInteractor::Base
allow_context_to_be_invalid
context_attributes :first_name, :last_name, :email, :user
context_validates :first_name, presence: true
end
context = MyInteractor.perform(email: '[email protected]')
context.valid? #=> false
context.success? #=> true
Context Attribute Callbacks
We can ensure only properties in the context's attributes are
returned after perform is invoked with the clean_context_on_completion
class method:
class MyInteractor < ActiveInteractor::Base
clean_context_on_completion
context_attributes :user
def perform
context.user = User.create_with(
occupation: context.occupation
).find_or_create_by(email: context.email)
end
end
context = MyInteractor.perform(email: '[email protected]', occupation: 'Software Dude')
context.email #=> nil
context.occupation #=> nil
context.user #=> <#User email='[email protected]', occupation='Software Dude'>
Perform Callbacks
We can do work before perform is invoked with the before_perform method:
class MyInteractor < ActiveInteractor::Base
before_perform :print_start
def perform
puts 'Performing'
end
private
def print_start
puts 'Start'
end
end
context = MyInteractor.perform
"Start"
"Performing"
We can do work around perform invokation with the around_perform method:
class MyInteractor < ActiveInteractor::Base
context_validates :first_name, presence: true
around_perform :track_time, if: :context_valid?
private
def track_time
context.start_time = Time.now.utc
yield
context.end_time = Time.now.utc
end
end
context = MyInteractor.perform(first_name: 'Aaron')
context.start_time #=> 2019-01-01 00:00:00 UTC
context.end_time # #=> 2019-01-01 00:00:01 UTC
context = MyInteractor.perform
context.valid? #=> false
context.start_time #=> nil
context.end_time # #=> nil
We can do work after perform is invoked with the after_perform method:
class MyInteractor < ActiveInteractor::Base
after_perform :print_done
def perform
puts 'Performing'
end
private
def print_done
puts 'Done'
end
end
context = MyInteractor.perform
"Performing"
"Done"
Rollback Callbacks
We can do work before rollback is invoked with the before_rollback method:
class MyInteractor < ActiveInteractor::Base
before_rollback :print_start
def rollback
puts 'Rolling Back'
end
private
def print_start
puts 'Start'
end
end
context = MyInteractor.perform
context.rollback!
"Start"
"Rolling Back"
We can do work around rollback invokation with the around_rollback method:
class MyInteractor < ActiveInteractor::Base
around_rollback :track_time
private
def track_time
context.start_time = Time.now.utc
yield
context.end_time = Time.now.utc
end
end
context = MyInteractor.perform
context.rollback!
context.start_time #=> 2019-01-01 00:00:00 UTC
context.end_time # #=> 2019-01-01 00:00:01 UTC
We can do work after rollback is invoked with the after_rollback method:
class MyInteractor < ActiveInteractor::Base
after_rollback :print_done
def rollback
puts 'Rolling Back'
end
private
def print_done
puts 'Done'
end
end
context = MyInteractor.perform
context.rollback!
"Rolling Back"
"Done"
We can do worker before perform is invoked on each interactor in an Organizer with the before_each_perform method:
class MyInteractor1 < ActiveInteractor::Base
before_perform :print_name
def perform
puts 'MyInteractor1'
end
end
class MyInteractor2 < ActiveInteractor::Base
before_perform :print_name
def perform
puts 'MyInteractor2'
end
end
class MyOrganizer < ActiveInteractor::Organizer
before_each_perform :print_start
organized MyInteractor1, MyInteractor2
private
def print_start
puts "Start"
end
end
MyOrganizer.perform(name: 'Aaron')
"Start"
"MyInteractor1"
"Start"
"MyInteractor2"
#=> <MyOrganizer::Context name='Aaron'>
We can do worker around perform is invokation on each interactor in an Organizer with the around_each_perform method:
class MyInteractor1 < ActiveInteractor::Base
before_perform :print_name
def perform
puts 'MyInteractor1'
end
end
class MyInteractor2 < ActiveInteractor::Base
before_perform :print_name
def perform
puts 'MyInteractor2'
end
end
class MyOrganizer < ActiveInteractor::Organizer
around_each_perform :print_time
organized MyInteractor1, MyInteractor2
private
def print_time
puts Time.now.utc
yield
puts Time.now.utc
end
end
MyOrganizer.perform(name: 'Aaron')
"2019-04-01 00:00:00 UTC"
"MyInteractor1"
"2019-04-01 00:00:01 UTC"
"2019-04-01 00:00:02 UTC"
"MyInteractor2"
"2019-04-01 00:00:03 UTC"
#=> <MyOrganizer::Context name='Aaron'>
We can do worker after perform is invoked on each interactor in an Organizer with the after_each_perform method:
class MyInteractor1 < ActiveInteractor::Base
before_perform :print_name
def perform
puts 'MyInteractor1'
end
end
class MyInteractor2 < ActiveInteractor::Base
before_perform :print_name
def perform
puts 'MyInteractor2'
end
end
class MyOrganizer < ActiveInteractor::Organizer
after_each_perform :print_done
organized MyInteractor1, MyInteractor2
private
def print_done
puts "done"
end
end
MyOrganizer.perform(name: 'Aaron')
"MyInteractor1"
"Done"
"MyInteractor2"
"Done"
#=> <MyOrganizer::Context name='Aaron'>
Using Interactors
Most of the time, your application will use its interactors from its controllers. The following controller:
class SessionsController < ApplicationController
def create
if user = User.authenticate(session_params[:email], session_params[:password])
session[:user_token] = user.secret_token
redirect_to user
else
flash.now[:message] = "Please try again."
render :new
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
can be refactored to:
class SessionsController < ApplicationController
def create
result = AuthenticateUser.perform(session_params)
if result.success?
session[:user_token] = result.token
redirect_to result.user
else
flash.now[:message] = t(result.errors.)
render :new
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
given the basic interactor:
class AuthenticateUser < ActiveInteractor::Base
context_attributes :email, :password, :user, :token
context_validates :email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
context_validates :password, presence: true
context_validates :user, presence: true, on: :called
def perform
context.user = User.authenticate(
context.email,
context.password
)
context.token = context.user.secret_token
end
end
The perform class method is the proper way to invoke an interactor.
The hash argument is converted to the interactor instance's context.
The preform instance method is invoked along with any callbacks and validations
that the interactor might define. Finally, the context (along with any changes made to it)
is returned.
Kinds of Interactors
There are two kinds of interactors built into the Interactor library: basic interactors and organizers.
Interactors
A basic interactor is a class that includes Interactor and defines call.
class AuthenticateUser
include Interactor
def perform
if user = User.authenticate(context.email, context.password)
context.user = user
context.token = user.secret_token
else
context.fail!
end
end
end
Basic interactors are the building blocks. They are your application's single-purpose units of work.
Organizers
An organizer is an important variation on the basic interactor. Its single purpose is to run other interactors.
class PlaceOrder
include Interactor::Organizer
organize CreateOrder, ChargeCard, SendThankYou
end
In the controller, you can run the PlaceOrder organizer just like you would any other interactor:
class OrdersController < ApplicationController
def create
result = PlaceOrder.call(order_params: order_params)
if result.success?
redirect_to result.order
else
@order = result.order
render :new
end
end
private
def order_params
params.require(:order).permit!
end
end
The organizer passes its context to the interactors that it organizes, one at a time and in order. Each interactor may change that context before it's passed along to the next interactor.
Rollback
If any one of the organized interactors fails its context, the organizer stops.
If the ChargeCard interactor fails, SendThankYou is never called.
In addition, any interactors that had already run are given the chance to undo themselves, in reverse order. Simply define the rollback method on your interactors:
class CreateOrder
include Interactor
def perform
order = Order.create(order_params)
if order.persisted?
context.order = order
else
context.fail!
end
end
def rollback
context.order.destroy
end
end
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.
You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install.
Additionally you can run tests in both rails 2.5 and rails 2.6 with bin/test.
Contributing
Read our guidelines for Contributing.
Acknowledgements
- Special thanks to @collectiveidea for their amazing foundational work on the interactor gem.
- Special thanks to the @rails team for their work on ActiveModel and ActiveSupport gems.
License
The gem is available as open source under the terms of the MIT License.