What is an Interactor?
Interactor is a Ruby gem whose main purpose is to help development teams encapsulate their business logic.
Technically, all interactors start as a PORO (Plain Old Ruby Object) which will represent one thing that our application does. Does that ring a bell? Have you heard of Service objects?
We’ll see how to give the “Interactor” behavior to our POROs or Service objects later in this post.
How to use them?
All you need to start using the Interactor gem is to include it in your Gemfile.
gem "interactor-rails", "~> 2.0"
Then, as usual, you need to complete the installation by executing:
bundle install
That’s it. You are now ready to start using Interactors in your Rails app!
At this point, you have two different options to start creating your own interactors. Let’s start with the easiest one.
Using the generator
The gem comes with a handy generator that allows you to create the interactor class with a simple command:
rails generate interactor send_email_notification
You will get the following file:
# app/interactors/send_email_notification.rb
class SendEmailNotification
include Interactor
def call
end
end
This approach is helpful if you are working in an app with an early development stage (fresh Rails app), and if you have not yet defined how to encapsulate business logic.
Including the module
As you might imagine from the previous example, any Ruby object can become an interactor. All you need to do is to include the module named `Interactor` in your class and implement or define a method named `call`.
That’s it. You have transformed your PORO into an Interactor.
This approach is useful if you are trying to move from Service objects to an Interactors approach. That way your Service objects can become Interactors.
Service Objects vs Interactors
Before we go deeper with Interactors’ functionality, let’s talk about this for a bit. Generally, the Service Objects approach is much better than doing everything in the Controllers.
I even prefer to use Service Objects over putting the heavy load of business logic in the Models. Some developers are comfortable with it because, as they tend to say, that’s what the Models are for. And yes, Models give us some tools to handle complex scenarios, e.g., Validations, Callbacks, Associations, Scopes, etc.
But have you heard of “The Callbacks Hell”? Well, I have seen it with my own eyes, and it’s a place that we should avoid whenever possible.
I prefer to keep the responsibility of my Models small enough i.e Associations, Simple Scopes, and Small Validations. For everything else, you can add new layers, like Presenters for the views or Service Objects/Interactors for business logic.
Why do I recommend Interactors over Service Objects? Interactors provide a standard way of performing actions within your application. The API they offer is really simple and small enough so you don’t have to deal with hundreds of methods at a time.
Service objects are POROs and in the end, we can write them as we want. You will be noticing soon that every developer in your team can come up with a different type or style of Service Object unless you define a guideline from the beginning.
With Interactors you can avoid that; there’s only one way to write them and they all expose the same API. That’s better for your codebase as it will look cleaner.
Let me guide you through the Interactor’s core concepts with a basic example.
Basic example
Let’s imagine we are working on an Internal Book Review app. Books are reviewed by users before they get published. The book’s author needs to be notified after a review is posted by a reviewer. If the review score is perfect we need to notify the Administrative team about it. When the review score is pretty low, we need to send a different notification. Finally, we need to calculate and refresh the average of the scores received by the book. Here is the controller that does all of that:
class ReviewsController < ApplicationController
before_action :authorize_reviews
# GET /books/:id/reviews/new
def new
@review = book.reviews.build
end
# POST /books/:id/reviews
def create
@review = book.reviews.create(
review_params.merge(reviewer_id: current_user.id)
)
if @review.persisted?
AuthorMailer.with(review: @review).review_received_email.deliver
if @review.score == 5
AdminMailer.with(review: @review).praise_author_email.deliver
elsif @review.score < 2
AdminMailer.with(review: @review).quality_control_email.deliver
end
BookStats.refresh(book: book, score: @review.score)
redirect_to book_path(book.id), notice: t(".success")
else
redirect_to action: :new, alert: t(".error")
end
end
private
def book
@_book ||= Book.find(params[:id])
end
def review_params
params.require(:review).permit(:title, :body, :score)
end
end
Now the code is shorter and clearer. It is a good practice to name our interactors with a descriptive name of the action that is being performed. Try to name them thinking on your business needs and not on the technical implementation.
All an Interactor object needs to do is to implement a method named `call` which will receive only the necessary context from the controller to do its job, not a single bit more.
If the result of the interaction is successful then we can redirect or render whatever we need. We can also handle the scenario whenever the Interactor fails to succeed.
Let me show you the Interactor code:
# app/interactors/create_review.rb
class CreateReview
include Interactor
def call
create_review
update_book_stats
notify_author
notify_admin_team
end
private
def create_review
context.review = context.book.reviews.create(
context.params.merge(reviewer_id: context.reviewer.id)
)
context.fail! unless context.review.persisted?
end
def update_book_stats
BookStats.refresh(book: context.book, score: context.review.score)
end
def notify_author
AuthorMailer.with(
review: context.review
).review_received_email.deliver
end
def notify_admin_team
if context.review.score == 5
AdminMailer.with(
review: context.review
).praise_author_email.deliver
elsif context.review.score < 2
AdminMailer.with(
review: context.review
).quality_control_email.deliver
end
end
end
With this example, you will notice that everything we passed to the `call` method from the controller is accessible and being used as a property in `context`. After the interactor does its job, the context will reflect all changes made by us and they will be available to us in the controller.
The only way this interactor can fail at this point is when we manually call the `context.fail!` method. Here in our example, it happens within the `create_review` method. And it means that the review was not saved in the database because of a validation error.
After the `context.fail!` method is called within an interactor, the execution is halted and returned early to the place the interactor was called (in this case, the controller). In our example, if the review can’t be persisted, then we won’t get any notifications, nor will the book stats be updated.
You might be thinking, Hey, we just added more code in a different place, and the controller was really simple the way it was before. And you are right, but in the real world, business logic can get more and more complex sooner than later.
Requirements can change at any moment, and at this point, we have already encapsulated the Review creation process in a single place. What if in the future we are asked to import reviews from a CSV file? Or to create reviews from an external API call or webhook? We could easily reuse our `CreateReview` interactor in those scenarios. We might only need small tweaks to support them.
That’s the main advantage of encapsulating business logic within Interactors.
Conclusion
Defining a standard way of encapsulating business logic in your Rails application is a win-win. Whether you use Service Objects, Interactors, or some other methodology, the end goal is to keep your code clean, well-designed, and easy to understand for the newest members of the development team.
If we use Interactors the right way, we will fulfill what’s stated by the following acronyms:
- KISS (Keep it simple)
- DRY (Don’t repeat yourself)
- SRP (Single Responsibility Principle)
Obtaining all of that for the small price of adding a new layer/dependency is totally worth it.
Here at FullStack Labs, we help businesses thrive and reach their goals. If you’re looking for Ruby on Rails consultancy services, do not hesitate to contact us.