Ruby interactors: a review

I've been playing with the Ruby community's interactor pattern and found a few issues.

Written by Rico Sta. Cruz
(@rstacruz) · 3 Dec 2021

The Ruby and Rails communities often use a gem (package) called interactor which aims to make logic encapsulated into self-contained pieces. After writing a few interactors myself, I came across a few problems that I thought I’d share.

The messy contexts problem

Interactors are often used to wrap code that will receive an input, and return an output. Here’s an example that takes some input (email, name) and returns output (user):

interactor_example.rb
result = RegisterUser.call(
name: 'John'
)
result.success? # => true
result.user # => User
RegisterUser here is an interactor using the interactor Ruby gem.

Working with contexts

The way interactors handle this is by using a context object that holds both inputs and outputs. I’ve found that having one object hold both inputs and outputs gets messy very quickly.

registered_user_interactor.rb
module RegisterUser
include Interactor
def call
create_user
create_profile
track_analytics
end
def create_user
context.user = User.create(
email: context.email,
is_admin: context.is_admin || false
)
end
def create_profile
context.profile = Profile.create(
user_id: context.user.id,
name: context.name
)
end
def track_analytics
Analytics.track('NEW_USER', uid: context.user.id)
end
end
RegisterUser.call(email: '[email protected]', name: 'John')
result.success? # => true
result.email # => string (input)
result.is_admin # => string (input)
result.profile # => Profile (output)
result.user # => <User> (output)
Some code comments were necessary to figure out which are inputs and which are outputs.

The shared contexts problem

Interactors provide an Organizer class which allows breaking apart interactors into smaller, reusable interactors. However, doing so often means the concept of “inputs” and “outputs” are a bit blurred.

module RegisterUser
include Interactor::Organizer
organize(
CreateUserRecord,
CreateProfile,
TrackAnalytics
)
end
module CreateUserRecord
include Interactor
def call
user = User.new(context.email, context.is_admin) # Input
context.user = user # Output
end
end
module CreateProfile
include Interactor
def call
profile = Profile.new(context.user, context.email) # Input
context.profile = profile # Output
end
end
module TrackAnalytics
include Interactor
def call
user = context.user # Input
Analytics.track!(user.id)
end
end
In this example, the RegisterUser class can be refactored into 3 sub-interactors. At this point, the inputs and outputs start to get difficult to make sense of.

Making sense of it

I’ve found that the more organisers are used, the more context becomes harder to manage. One way to keep track of this is making some graph that keeps track of these things. (These tables can get quite difficult to manage very quickly.)

Interactoremailis_adminuserprofile
RegisterUserininoutout
CreateUserRecordininout
CreateProfileinout
TrackAnalyticsin
👋
Hey! I write articles about web development and productivity. If you'd like to support me, subscribe to the email list so you don't miss out on updates.

The reusability problem

Interactors are often touted for being useful for managing reusable pieces of logic. Here’s one example of business logic that might be useful in many places.

validate_email
module ValidateEmail
include Interactor
def call
email = context.email
unless EMAIL_REGEXP.match(email)
context.fail! error: :invalid_format
end
if User.where(email: email).count != 0
context.fail! error: :email_is_already_taken
end
end
end
This interactor takes in a context.email input, and throws an error if it's not valid.

Not easily reusable

While this logic is nicely self-contained, it’s not easily reusable in an organizer that might take in a different input shape.

send_page_to_friend.rb
module SendPageToFriend
include Interactor::Organizer
organize(
ValidateEmail, # <-- ! not working as intended!
DoSendPageToFriend
)
end
SendPageToFriend(
params: {
message: 'Have a look!'
}
)
This interactor takes in context.params.email, not the context.email expected by ValidateEmail. In this case, ValidateEmail can't be re-used as-is.

The error handling problem

Nested interactors break error handling. Because Interactor Organisers enforce the use of the same context fields, it’s often better to call interactors directly from other interactors.

module NestedInteractor
def call
ValidateEmail.call!(email: context.params.email)
# Question: will an error in the line above
# prevent this next line from working?
do_work_after_validation
end
end
The intention here is to have call! stop the execution when an error is encountered. However, it doesn't work that way...

Errors are swallowed by default

Unfortunately, the code above wouldn’t work, because errors are swallowed by default even when using call!. The fix here is to intentionally rescue Interactor errors and re-raise them using context.fail!.

module NestedInteractor
def call
ValidateEmail.call!(email: context.params.email)
do_work_after_validation
rescue Interactor::Failure => e
context.fail!(error: e.context.error)
end
end
The rescue block will be needed when using call! inside interactors. See the discussion on GitHub.

Suggested solutions

Here are a few ideas I had on how to work around these limitations with interactors.

Avoid organisers

Organisers impose strict restrictions on how code is supposed to be structured, and I feel that the restrictions don’t necessarily make for better code. It’s not worth the extra effort in my opinion, and nested interactors are a more reasonable alternative.

Consider validating inputs

Gems like dry.rb allows writing runtime validation for types. Static compile-time type checking is most ideal in my opinion (eg, Ruby type signatures or Sorbet), but runtime validation is the closest alternative.

Catch nested iterator errors, or avoid fail!

Many devs I talked to who uses interactors have written some code to fix the shortcomings of fail! errors being swallowed. This snippet might be good to extract into somewhere easy to reuse:

interactor_with_failure_handler.rb
module MyInteractor
include Interactor
def handle_errors(&block)
yield
rescue Interactor::Failure => e
context.fail!(error: e.context.error)
end
end
# Now errors can be propagated instead of being
# silently swallowed:
handle_errors do
MyOtherInteractor.call!
end
This is a workaround to allow for fail! errors to propagate taken from this comment on GitHub.

Document inputs and outputs

Since it’s easy to get lost in what parts of a context is input or output, I found that it helps to document what each interactor’s inputs and outputs are.

# == Inputs
# [user_id] (string) The user ID
# [use_defaults] (boolean, optional)
#
# == Outputs
# [categories] (Category[])
module CreateUseCategories
include Interactor
def call; ... end
end

Use plain Ruby instead of interactors

Many scenarios involving interactors can be done using plain Ruby modules. Consider if the addition of a gem like interactor is worth it over writing service objects in a different way.

Written by Rico Sta. Cruz

I am a web developer helping make the world a better place through JavaScript, Ruby, and UI design. I write articles like these often. If you'd like to stay in touch, subscribe to my list.

Comments

More articles

← More articles