I’ve spent a few months now using the interactor gem, both in my day job and also quite extensively in GitArborist’s code. My first time hearing about “interactors” was from a talk by Robert Martin on clean architecture, essentially saying that an “interactor” object should encapsulate a single “use case” or user story, basically the business logic. Therefore, if you look at a list of interactors in the system you can quickly determine what operations that system carries out (in theory).
“Service Objects”
In some circles, these kinds of “business logic” objects are called Service Objects. They typically have a single public method (e.g. call
or execute
), sometimes even with a simple class-level method to simplify their usage. Generally, these objects have no state and are essentially procedural code wrapped in Object-Oriented trimmings.
class DoTheThing
def self.call(*args)
new.call(*args)
end
def call(a, b)
a + b
end
end
DoTheThing.call(1, 2)
=> 3
The idea here is to break the code into small, isolated, pieces that can be easily understood and tested in isolation.
The Interactor Gem
There are various “Service Object” libraries, or you can simply roll-your-own using something like the snippet above. The interactor gem has some niceties it provides out of the box. Firstly, every interactor provides a call
method, which is (typically) the only public method. The gem wraps this for you so calling an interactor becomes InteractorClass.call(...)
, similar to the snippet above.
The return value, however, is a “context” object provided by the gem itself.
{{ <midroll_ad> }}
The context
Object
The context
has success?
and failure?
helpers for you to determine what path to take after calling the interactor.
If an exception occurs this is automatically caught and results in a failed context being returned. Failure can also be set manually within an interactor by calling context.fail!(...)
.
Beyond that, the fields on context
are whatever you want them to be. Anything passed into call
becomes a field on the context, and you can assign new entries on-the-fly via context.new_context_field = 123
in your interactor.
Organizers
One of the real value-adds of the interactor gem is the concept of Organizers. An Organizer is a special type of interactor that exists only to join other interactors together in sequence (which could themselves be other organizers). If any of the interactors in the list fail, then execution is halted and returns early. This is great for the one-happy-path-with-many-possible-error-states type of business logic. Consider, for example, processing a user signup form:
class ProcessSignup
include Interactor::Organizer
organize ValidateEmail,
ValidatePassword,
CreateUser,
SendWelcomeEmail
end
In this example, we would run through each step only if the one before it succeeded. The code that called ProcessSignup
then only has to handle a single success?
or failure?
outcome without worrying about all the various ways this could have failed (though the context should probably contain an error message to display to the user or otherwise indicate the exact error that occurred).
The context is also automatically passed from one interactor to the next. So any field added to the context by ValidateEmail
will be available to ValidatePassword
and so on.
Recommended Conventions
The context
can become a bit of global-variable-soup if not cared for properly. To avoid that here’s some conventions/rules I would suggest:
1. Use delegate
for all incoming arguments used in the interactor
Interactors can use delegate
to avoid having to use the context.
prefix everywhere. More importantly, they also give you a single place to look to see what arguments are required when calling the interactor.
class ProcessSignup
include Interactor
delegate :name, :email, to: :context
def call
...
2. Push return values to the bottom of call
Similar to the first recommendation, by placing outgoing variables at the end of the method you have a single place to look to see what the interactor is returning (if anything). Of course, this is not always possible when you have early returns for errors.
3. Use a common way to report errors
When returning failure via context.fail!(...)
you can pass in values that are added to the context. For example: context.fail!(error: "Email is not unique")
. The key here is to always use the same convention (e.g. either error: ...
or message: ...
, not a mix of both). Also, decide whether or not the errors being returned are displayed to users or not (ideally the error is either always intended to be user-readable or never needs to be user readable).
4. Naming conventions
Beyond that, the organizers are where the interactor gem comes into its own. The best benefit is when the interactors are small, self-contained operations that can be re-used by multiple organizers. I’d also recommend you name the interactors after the action they perform.
I’m a self-confessed fan of naming conventions for things like this, for example in GitArborist interactors that handle incoming events all have a Handle
prefix, while ones that process user comments have a Process
prefix.
Shortcomings
The main thing I wish the gem did differently is handling incoming arguments. While delegate
is useful and does its job, I would appreciate a way to specify optional vs required arguments, with the latter raising an exception if it is missing.