Using Interactors in Rails


Often when working with Rails we hear about fatty controllers and skinny models. We also come across complex user interactions where we need to perform tasks sequentially on particular user action.

There are various ways to solve the above problems using service objects, form objects, policy objects, etc.

One such way to refactor code is to use Interactor gem.

We need to add the interactor gem in our Gemfile and execute bundle install

gem "interactor"

Interactors

class AddNumbers
  include Interactor

  def call
    context.sum = context.a + context.b
  end
end

# calling the above interactor
result = AddNumbers.call(a: 1, b: 2)

puts result.failure?
=> false

puts result.sum
=> 3

As seen in the above example

  • We created a small class AddNumbers and included Interactor in the class.

  • We passed two arguments to call method a and b. They can be accessed inside the interactor using the context object as context.a and context.b.

  • We also set the sum inside the context using context.sum = context.a + context.b.

  • Basically context contains everything the interactor needs to do its work.

  • AddNumbers.call(a: 1, b: 2) returns a context object and we can check if the above code executed successfully or not by checking result.failure?

  • We can check the sum by executing result.sum

  • If something goes wrong in the interactor we can flag the context as failed by context.fail! and also set the error message as context.fail!(error: "Error message"). Above example can be modified as

class AddNumbers
  include Interactor

  def call
    context.sum = context.a + context.b
  rescue => e
    context.fail!(error: e.message)
  end
end

result = AddNumbers.call(a: 1, b: "abcd")

puts result.failure?
=> true

puts result.error
=> String can't be coerced into Integer

The interactor also provides before, around and after hooks. We can think of them to be similar to before_action, around_action and after_action used in controllers.

To avoid rescue in the above example we can add a before hook to check if the numbers are integer or not and fail the context accordingly. We can initialize sum to 0 as shown below.

class AddNumbers
  include Interactor

  before do
    context.fail!(error: "Should pass integers") unless a.is_a?(Integer) && b.is_a?(Integer)
  end

  before do
    context.sum = 0
  end

  def call
    context.sum = context.a + context.b
  end
end

The above interactor is a single-purpose unit interactor. A complex system might involve multiple interactors which need to be called in a sequence.

Organizers

To execute a sequence of interactors we can use organizers provided by this gem. Imagine a situation where we need to pull data of users from different social media accounts and import it into our database.

The basic steps we might follow to import the users will be:

  • Fetch data from the social media account.
  • Convert data into standard format which can be imported to our system.
  • Import data into our system.

Each of the above steps can be seen as a single unit and we can create interactors for each step. The above steps can be grouped in an organizer as below:

class ImportSocialMediaUsers
  include Interactor::Organizer

  organize FetchUsers, ConvertUsersDataIntoStandardFormat, ImportUsers
end

class FetchUsers < BaseInteractor
  def call
    context.raw_users_data = context.client.fetch_users
  end
end

class ConvertUsersDataIntoStandardFormat < BaseInteractor
  def call
    context.standard_users_data = context.client.format_users_data
  end
end

class ImportUsers < BaseInteractor
  def call
    # code to import users
  end
end

class BaseInteractor
  include Interactor
end

We have created three interactors inside ImportSocialMediaUsers organizers. The three interactors will be called one after another in a sequence.

If any interactor fails then next interactors are not called and the respective error is returned.

So to check if the flow was executed successfully we can use #success? or #failure? method as below

result = ImportSocialMediaUsers.call(client: Facebook.new)

if result.success?
  puts "All users imported successfully"
else
  puts "Error when importing users :: #{result.error}"
end

As per the above example, we observe Facebook.new is passed as a client to the organizer ImportSocialMediaUsers.

So our Facebook class will look like this

class Facebook
  def initialize
    # Facebook client id and client secret
  end

  def fetch_users
    # API call to fetch users
  end

  def format_users_data
    # Code which maps and converts Facebook user details to our
    # standard user details.
  end
end

If we decide to integrate Twitter, and import users from Twitter we just need to implement the Twitter class and pass its instance to the ImportSocialMediaUsers organizer as below:

class Twitter
  def initialize
    # Twitter client id and client secret
  end

  def fetch_users
    # API call to fetch users
  end

  def format_users_data
    # Code which maps and converts Twitter user details to our
    # standard user details.
  end
end

result = ImportSocialMediaUsers.call(client: Twitter.new)

The interactor and organizers help to refactor the code into smaller single units making them easily testable and reusable.