Functional Interactor
Based around https://github.com/collectiveidea/interactor, reimagined to use Kase with composability operators.
Getting Started
Add Interactor to your Gemfile and bundle install
.
gem "functional_interactor", "~> 0.0.1"
This implementation is meant to be used with the Kase gem. This is also an experiment -- some of the ideas such as generic interactors are somethign we (Legal.io engineering) is trying due to the sheer complexity of our code base. The examples we have in the advanced usage section can use a lot of improvement.
What is an Interactor?
An interactor is a simple, single-purpose object.
Interactors are used to encapsulate your application's business logic. Each interactor represents one thing that your application does.
Call and Return Protocol
An Interactor must respond to the method call
, and takes a single object.
An Interactor following this protocol will accept a single object which encapsulates the state or context. By convention, we use a Hash-like object so that interactors can be composed into higher-order interactions.
Success
When the action succeeds, return
[:ok, context]
The return value of context
can be anything, though it is suggested that you
stick with a Hash-like object so that interactors can be chained together.
Failure
When the action fails, return an array where the first element is the symbol
:error
. Examples:
[:error, "This failed"]
[:error, :invalid, [{'field' => 'must be present'}]]
[:error, :stripe_error, StripeException.new]
You are typically going to use Kase to handle errors:
Kase.kase PushUserToElasticSearch.call(user) do
on(:ok) do |ctx|
# Do something
end
on(:error, :network) do |reason|
NotifyHuman.log "failed to push user ##{user.id} to ElasticSearch"
end
end
Context
An interactor is given a context. The context contains everything the interactor needs to do its work.
When an interactor does its single purpose, it affects its given context.
Context are assumed to be a Hash like object.
Adding to the Context
As an interactor runs it can add information to the context.
context[:user] = user
Hooks
This implementation has no hooks.
An Example Interactor
Your application could use an interactor to authenticate a user.
class AuthenticateUser
include FunctionalInteractor
def call(context = {})
user = User.authenticate(context[:email], context[:password])
return [:error, :not_authenticated] unless user
context[:user] = user
context[:token] = user.secret_token
# Return a new context so we are not modifying the original
[:ok, { user: user, token: user.secret_token }]
end
end
To define an interactor, simply create a class that includes the Interactor
module and give it a call
instance method. The interactor can access its
context
from within call
.
Interactors in the Controller
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
Kase.kase AuthenticateUser.call(session_params) do
on(:ok) do |result|
session[:user_token] = result[:token]
redirect_to root_path
end
on(:error, :not_authenticated) do
flash.now[:message] = t(result.message)
render :new
end
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
The .call
class method simply instantiates a new AuthenticatedUser
interactor
and passes the context to it. This allows us to create generic interactors
that can be inlined and composed together. This is discussed in the following section.
Advanced Usage
Sequences
creativeideas/interactor
has an Organizer
class. We have a similar code called
Interactors::Sequence
.
Let's define a second interactor:
class NotifyLogin
include FunctionalInteractor
def call(context = {})
NotificationsMailer.login(user: context[:user]).deliver
[:ok, context]
end
end
We can then chain them together like so:
interactions = Interactors::Sequence.new
interactions.compose(AuthenticatedUser)
interactions.compose(NotifyLogin)
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
Here, the Interactors::Sequence
object holds a sequence of
interactions. It will call them one by one, starting from the top. If
at any point, it returns something with [:error, ...]
then the chain
will stop. We can then use Kase
to handle the error.
#compose
and |
We do not actually have to create an Interactors::Sequence
object. The
#compose
method will create an Interactors::Sequence
for you. You can
chain them together like so:
interactions = AuthenticatedUser.compose(NotifyLogin)
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
We also aliased |
so you can use that instead:
interactions = AuthenticatedUser | NotifyLogin
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
Generic Interactors
Sometimes we want to dynamically create an interactor. We can change the notification interactor to:
interactions = AuthenticatedUser \
| Interactors::Anonymous.new do
NotificationsMailer.login(user: context[:user]).deliver
[:ok, context]
end
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
There is a helper, Interactors.new
that can simplify that:
interactions = AuthenticatedUser \
| Interactors.new do
NotificationsMailer.login(user: context[:user]).deliver
[:ok, context]
end
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
Since we don't care about handling errors, we can Interactors::Simple
instead:
interactions = AuthenticatedUser \
| Interactors::Simple.new { NotificationsMailer.login(user: context[:user]).deliver }
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
This might seem like a lot for just a simple mailer. The real value comes from when there is a long chain of interactions:
interactions = AuthenticatedUser \
| FraudDetector \
| Interactors::Simple.new { NotificationsMailer.login(user: context[:user]).deliver } \
| ActivityLogger.new(:user_logs_in, controller: self) \
| Interactors::RPC.new(service: :presence, module: :'Elixir.Presence.RPC', func: :register)
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
Custom Generic Interactors
Generic interactors work because we can override the constructor. In the case of a Rails mailer, maybe we want to have a generic mailer:
class Interactors::Mailer
include FunctionalInteractor
def new(mailer:, method:)
@mailer = mailer
@method = method
end
def call(context = {})
mailer.send(method, context)
[:ok, context]
end
end
In which case, we can then use that:
interactions = AuthenticatedUser \
| Interactors::Mailer.new(mailer: NotificationsMailer, method: :login)
Kase.kase interactions.call(session_params) do
on(:ok) { |context| puts "Yay! Logged in!" }
on(:error) { |context| puts "Failed to login" }
end
Further Discussion
collectiveideas/interactor
has a great section discussing on when to
use interactors: https://github.com/collectiveidea/interactor#when-to-use-an-interactor