Rails Interactors - Reconstruct Your Application

Rails Interactors - Reconstruct Your Application

Everything you need to know about Rails Interactors

In my previous article, we explored the purpose of service objects and their role in encapsulating external business logic, particularly when interacting with third-party APIs. However, in scenarios where we need to extract the business logic of a specific use case into an object, traditional service objects may not offer all the necessary functionalities. This is where the Rails Interactors gem comes into play. Interactors build upon the concept of service objects and provide additional features to enhance overall functionality and improve code organization.

The Rails Interactors gem offers a structured approach to handling complex use cases by breaking them down into smaller, more manageable components. These components, known as interactors, encapsulate the business logic of a particular use case. Interactors enable better separation of concerns, enhance code reusability, and improve testability.

Initial Setup

We will begin by setting up the Interactor Gem and then proceed with an example. The Interactor Gem can be installed by first adding this line in your application's Gemfile.

gem 'interactor'

Then, just need to run bundle install to install the Gems and their dependencies

bundle install

Well, that's it! Now your application is ready to use Interactors. Now there are two different ways to create interactors

  1. Use the generator: The Gem comes with a generator that allows us to create an interactor class just by running the following command:

     rails generate interactor example_interactor
    
  2. Include the module: Another way to create an interactor is by including the interactor module in your class and implementing a method named call. That's it! Just by following these two steps, your PORO is now an Interactor.

Finally, there is one last step to complete the setup, which is the addition of the application_interactor.rb and application_organizer.rb (Organizers covered later in this article) files. This step is optional and depends on your specific application requirements, but it is generally considered a good practice.

By creating these files, you establish a central place to define common functionality and configurations for your Interactors and Organizers. This promotes code reusability and helps maintain consistency throughout your application. In these files, you can define a base class for your Interactors that includes any shared methods or behavior. For example, you may want to add methods for error handling, logging, or authorization that apply to multiple Interactors in your application.

By separating these common functionalities into base classes, you can easily extend and customize your Interactors and Organizers as needed, while keeping the overall structure and behavior consistent.

Remember that adding these files is optional, and you can always start without them. However, as your application grows and you find yourself reusing certain functionality across Interactors and Organizers, introducing these files can help streamline your development process and improve code maintainability.

The files should look something like this:

Application Interactor

class ApplicationInteractor
  include Interactor
    class << self
        def call(context = {})
          context ||= {}
          # code here
        end

        def call!(context = {})
          context ||= {}
          # code here
        end
    end
end

Application Organizer

class ApplicationOrganizer
  include Interactor::Organizer
    class << self
        def call(context = {})
          context ||= {}
          # code here
        end

        def call!(context = {})
          context ||= {}
          # code here
        end
    end
end

After this process, the directory structure of your application should look something like this:

interactors
├── application_interactor.rb
├── application_organizer.rb
└── movie_manager
      └── get_movies_interactor.rb
      └── some_other_interactor.rb
      └── combining_organizer.rb
└── anime_manager
      └── get_anime_interactor.rb
      └── some_other_interactor.rb
      └── combining_organizer.rb

Basic Concepts

Before moving towards an example, let's cover some of the basics of Interactors.

  1. Organizers are a way to compose multiple Interactors together to handle more complex use cases or workflows. They provide a structured approach to coordinating the execution of Interactors and managing the flow of data between them. Another great thing about Organizers is that if any one of the organized interactors fails its context, the organizer stops.

  2. Contexts serve as a container for data and state relevant to the interactor's execution. The context object's data is accessible throughout the interactor. We can implement a context class that inherits from Interactor::Context. For example:

     class CreateUserContext < Interactor::Context
       attr_accessor :name, :email
     end
    

    Additionally, the context object in the Interactor gem provides a mechanism for handling failures and exceptions. If an issue arises within your interactor, you can indicate a failure by invoking the context.fail! method. This method allows you to store an error message in context.error as well. Moreover, you can check whether the context represents a failure or success using context.failure? and context.success? respectively.

    This might seem overwhelming now, but there's no need to worry. Once we dive into examples and explore the concept further, the workings will become apparent.

  3. Hooks in interactors are used to implement before, after and around hooks.

    There can be times when an interactor needs to prepare its context before the interactor is even run. This can be done with before hooks on the interactor. For example:

     before do
       context.is_loggedin = false
     end
    
     ###########################################################
    
     before :is_loggedin
    
     def is_loggedin
       context.is_loggedin = false
     end
    

    Like in the above example we can either make a block preparing a context or pass a symbol executing a method describing a functionality.

    Then we have the after hooks. These can be used to perform teardown operations after the interactor instance is run. For example:

     after do
       context.user.reload
     end
    
     ###########################################################
    
     after :reload_user
    
     def reload_user
       context.user.reload
     end
    

    Finally, we have the around hooks. The around hooks can be implemented the same way as before or after hooks, using either a block or a symbol method name, but there is one thing that is a bit different. This hook allows you to define code that will be executed before and after the interactor's call method. It wraps the entire execution of the interactor.

     class MyInteractor
       include Interactor
    
       around do |interactor|
         # Code executed before the interactor's `call` method
         puts "Before executing the interactor"
    
         # Execute the interactor's `call` method
         interactor.call
    
         # Code executed after the interactor's `call` method
         puts "After executing the interactor"
       end
    
       def call
         # Core logic of the interactor
         puts "Executing the interactor"
       end
     end
    

    The around can also be extended to form the around_each hook. This hook allows you to define code that will be executed before and after each step of the interactor. It wraps the execution of each step individually.

     class MyInteractor
       include Interactor
    
       around_each do |interactor|
         # Code executed before each step
         puts "Before executing each step"
    
         # Execute the step
         interactor.call
    
         # Code executed after each step
         puts "After executing each step"
       end
    
       def call
         # First step
         puts "Executing the first step"
    
         # Second step
         puts "Executing the second step"
       end
     end
    

    By using hooks, you can add additional functionality or behavior around the core logic of the interactor, such as logging, transaction management, or error handling. It allows for better encapsulation and flexibility in managing the interactor's execution flow.

Creating Interactors

Now let's dive in deeper and move toward the practical part and implement some functionality using Interactors. Let's consider we have to implement a functionality that requires creating and listing movies. The interactors would look something like this:

# app/interactors/create_movie_interactor.rb
class CreateMovieInteractor < ApplicationInteractor
  include Interactor

  around do |interactor|
    ActiveRecord::Base.transaction do
      interactor.call
    rescue StandardError => e
      context.fail!(error: e.message)
    end
  end

  def call
    title = context.title
    description = context.description

    if title.blank?
      context.fail!(error: "Title cannot be blank")
      return
    end

    movie = Movie.create(title: title, description: description)
    context.movie = movie
  end
end
# app/interactors/list_movies_interactor.rb
class ListMoviesInteractor < ApplicationInteractor
  include Interactor

  after do
    if context.failure?
      context.movies = []
    end
  end

  def call
    movies = Movie.all
    context.movies = movies
  end
end
# app/interactors/movie_organizer.rb
class MovieOrganizer < ApplicationOrganizer
  include Organizer

  organize CreateMovieInteractor, ListMoviesInteractor

  around do |organizer|
    ActiveRecord::Base.transaction do
      organizer.call
    rescue StandardError => e
      context.fail!(error: e.message)
    end
  end
end
# app/controllers/movies_controller.rb
class MoviesController < ApplicationController
  def index
    result = MovieOrganizer.call

    if result.success?
      @movies = result.movies
    else
      flash[:error] = result.error
      @movies = []
    end
  end
end

In the above example, the around hook is added to the CreateMovieInteractor and MovieInteractorOrganizer to wrap the execution in a database transaction. This ensures that if any error occurs during the execution of the interactors, the transaction will be rolled back.

Additionally, the after hook is added to the ListMoviesInteractor to handle the scenario when the interactor fails. If the interactor fails, it sets an empty array for the context.movies to prevent any potential errors when accessing the movies in the controller.

By using interactor hooks, you can add additional behavior or wrap the execution in a transaction, providing better error handling and consistency in the application.

Then finally after creating the interactors, we will wrap them in an organizer to define the flow of execution.

Now, what was the motivation for refactoring such a small amount of code?

The motivation for refactoring such a small amount of code was to address the need for scalability and accommodate more complex use cases. Imagine a scenario where we have a more complex and large application, like a movie platform similar to Netflix. In addition to the existing functionality, we might want to send a free trial email after granting a free trial, provide recommended movies based on user interests upon registration, and incorporate various other features. As a result, the codebase would no longer remain small and straightforward. This represents the first point I mentioned earlier, emphasizing the necessity for refactoring.

Another point, as stated in the gem's documentation, is that by looking at the interactors directory, we can quickly understand the complete functionality of the application. In simpler terms, examining this directory gives us a clear overview of what the application does and how it operates.

Other Advantages

Here are some key points about Organizers in Rails:

  1. Composition of Interactors: Organizers allow you to define the sequence and dependencies of Interactors. You can specify the order in which Interactors should be executed and pass data between them as the workflow progresses.

  2. Handling Complex Use Cases: Organizers are useful when you have use cases that involve multiple steps or require coordination between different Interactors. By composing Interactors within an Organizer, you can break down the complex use case into smaller, more manageable units of work.

  3. Data Flow and Context: Organizers provide a context object that serves as a container for data that needs to be shared between Interactors. As the Organizer progresses through the defined sequence of Interactors, the context object allows passing data and maintaining the state of the workflow.

  4. Error Handling and Failure Scenarios: Organizers typically provide mechanisms to handle errors and failure scenarios. If any Interactor within the Organizer fails, the Organizer can halt the execution and handle the failure gracefully, such as rolling back changes or returning appropriate error messages.

  5. Reusability and Modularity: Organizers promote code reusability by allowing you to define common workflows and sequences of Interactors that can be used across different parts of your application. You can create Organizers for common use cases and invoke them wherever needed, enhancing the modularity and maintainability of your codebase.

  6. Testability: Organizers can be tested by asserting the expected sequence of Interactors and their respective outputs. You can write tests to verify that the Organizer executes the Interactors in the correct order and that the data flow and context are handled as expected.

Conclusion

We have explored the process of refactoring our code by implementing interactors, which allows us to achieve a clearer definition of our business rules and maintain a strict separation between controllers, models, and business logic. By using interactors, we can encapsulate specific actions and promote code reuse, resulting in cleaner and more organized code overall. This approach enhances the maintainability and readability of our application, making it easier to understand and modify as needed.

In a future article, we will be discussing other conventions we can use to make our application better and more scalable. So stay tuned and keep learning!