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
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
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.
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.
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 incontext.error
as well. Moreover, you can check whether the context represents a failure or success usingcontext.failure?
andcontext.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.
Hooks in interactors are used to implement
before
,after
andaround
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:
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.
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.
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.
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.
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.
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!