Encapsulating Ruby on Rails Business Logic with Interactors

Written by

In this blog post, I will show you a brief introduction on how to use the Interactor Ruby gem within your Rails application to easily encapsulate your business logic, along with my opinions on why I consider Interactors a good fit for any Rails application.

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.

Frequently Asked Questions