Technical writings of Shkrt
I had not found any well-known description for Interactors. For example, Interactor gem’s README provides following characteristic:
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.
Looking at interactor gem and also Hanami::Interactor I’ve built my image of interactors accordingly to following principles:
So, this writing is dedicated to implementing interactor in the simplest of possible ways.
First, let’s write the example part of the code that will use interactor logic.
class EventsController < WebsiteController
def fetch
response = FetchFeedbacks.new('http://example.com', 4).call.success?
if response.success?
redirect_to events_path, notice: 'Feedbacks fetching started'
else
redirect_to events_feedback_path, alert: response.errors
end
end
end
This code calls FetchFeedbacks service class and then redirects or shows error messages accordingly to the result of the call. This is how should interactor work in my opinion. Now let’s imagine, how what exactly should FetchFeedbacks class be to meet our conditions.
# fetch_feedbacks.rb
require 'uri'
require_relative 'interactor'
class FetchFeedbacks
include Interactor
def initialize(source, amount)
@source = source
@amount = amount
end
def call
if valid_source?(@source)
PostFeedbacks.perform_async(@source, @amount)
else
add_error('Invalid url passed')
end
self
end
private
def valid_source?(src)
src =~ URI.regexp
end
end
class PostFeedbacks
class << self
def perform_async(src, amount)
# ...
end
end
end
We had already put interactor logic into separate Interactor
module, thus all the classes including that mixin would have success?
and add_error
methods.
In call
method we validate input parameters, and if the condition is not met, we add error to interactor object’s errors. In a successful scenario, some background job named PostFeedbacks
is launched. In both scenarios, self
is returned. It’s worthful to note, that we are not limited to return the only that slim object,
containing success or failure status. We also can set custom instance variables for each object. Including Interactor module should only assure that we can call success?
, failure?
and add_error
methods on the resulting object.
But we have not written Interactor module yet. Let’s continue with writing specs for the FetchFeedbacks class. BTW I’m not a huge fan of writing tests for non-existent code, but in this case, we would benefit from doing this, because it would help us to have a clean representation of how Interactor module should be designed. Also, some people would say, that we should write specs for Interactor module itself, but I think that writing specs for collaborator class can show us Interactor requirements more clearly.
# test.rb
require 'rspec'
require_relative 'fetch_feedbacks'
RSpec.describe 'FetchFeedbacks' do
context 'when valid source provided' do
let(:subject) { FetchFeedbacks.new('http://example.com', 2).call }
it 'has not failure status' do
expect(subject.failure?).to be_falsy
end
it 'has success status' do
expect(subject.success?).to be_truthy
end
it 'has no errors' do
expect(subject.errors).to be_empty
end
it 'delegates work to PostFeedback class' do
expect(PostFeedbacks).to receive(:perform_async)
subject
end
end
context 'when no valid source provided' do
let(:subject) { FetchFeedbacks.new('1', 2).call }
it 'has errors' do
expect(subject.errors).not_to be_empty
end
it 'has failure status' do
expect(subject.failure?).to be_truthy
end
it 'has not success status' do
expect(subject.success?).to be_falsy
end
end
end
Now, let’s make tests green.
module Interactor
# set up included hook
def self.included(base)
# use prepend to override base class' initialize method
base.prepend(Initializer)
# use class_eval to set attributes and methods
base.class_eval do
attr_accessor :success
attr_reader :errors
# this attribute will indicate successful status - true by default
def success?
errors.empty?
end
# this attribute will indicate failure status - false by default
def failure?
!success?
end
# method to add errors
# wrapper around << method
def add_error(msg)
errors << msg
end
end
end
module Initializer
def initialize(*args, &block)
self.instance_variable_set(:@errors, [])
super
end
end
end
This implementation successfully passes all tests. Someone might ask “what’s the point of using wrapper method add_error
, if we still have a possibility of using <<
, push
, concat
, +
methods on object’s errors
array?”
This method added to provide custom error raising logic. If we want to manage errors array directly, this does not break any functionality, because any interactor object that has errors will remain in the failed status.
About interactors:
Hanami::Utils page - includes Interactor
A couple of words about interactors
[ruby
]