How to reduce controller bloat with interactors in ruby

How to Reduce Controller Bloat with Interactors in Ruby

Step out of the MVC box and learn how to keep your Ruby application enjoyable to work on.

Cut your Rails test suite down to a few minutes with one-click automatic parallelization.

Automate parallelizing tests

Introduction

Let's consider a hypothetical situation — You've been working on a Rails application for about a year. When the application was new and its functionality limited, you could add features relatively simply by spinning up a new controller, model and view or — worst case — add a few lines to an existing controller, model or view.

However, as time went by, the application's feature set kept increasing. Moreover, existing features had to be regularly changed to reflect the changes in business goals and priorities. Since there are only so many controllers, views and models that can pertain to a given application's domain, you're noticing that the number of lines in these files is slowly increasing as well. Combined with the frequent changes and updates to existing features ,which introduce quite a few edge cases, you're realizing that your codebase is becoming harder to understand, test and ultimately change.

In this tutorial, we'll take a look at how we can prevent the situation described above from happening or fix it if it has already happened by using the "Interactor" pattern. We'll start with a general discussion of code smells, in particular smells which indicate bloated code, and their prescriptive refactorings. Then, we'll take a brief look at where these smells are typically found in a Rails application and the different techniques available to refactor them, the Interactor pattern being one of them. Finally, we'll go through an example with code that will illustrate how an Interactor is used in practice.

Code Smells

The most important quality of any piece of software, apart from the fact that it works, is how easy it is to understand and change it. Code smells are warning signs that our software is getting hard to understand and change.

Martin Fowler, in his classic book Refactoring, laid out 23 types of code smells, each with a prescriptive refactoring. As Sandi Metz mentioned in her RailsConf 2016 talk, there are two important "big picture" takeaways from this book:

  1. Each code smell has a descriptive name and definition, e.g. "Long Method".
  2. Each code smell can be refactored with one or more prescriptive refactorings that are associated with it. Each refactoring also has a name and a clear definition, e.g. "Replace Method with Method Object".

Contrary to the view that a code smell refers vaguely to "ugly" code, Fowler's precise definitions allow us to assess how much code smell there actually is in our codebase. That being said, a code smell ought to be treated as an indication that a problem might exist and not as definitive proof — this is where intuition and experience come into play.

In general, being cognizant of code smells and the tools available to fix them will result in your code being easier to change in the future.

Long Methods and Large Classes

In their paper Subjective evaluation of software evolvability using code smells: An empirical study, Mika V. Mäntylä & Casper Lassenius categorized the 23 code smells into five distinct categories. One of these categories is known as the "Bloaters", and as the name implies, contains the smells which most indicate bloat in our code. The "Bloaters" category contains the following code smells:

  • Long Method,
  • Large Class,
  • Primitive Obsession,
  • Long Parameter List, and
  • Data Clumps.

In Rails applications which resemble the hypothetical one described in the introduction, growth of the feature set is tied directly to growth in the size of controllers and models. Growth in the size of controllers and models implies that the classes and the methods they contain are getting bigger. As one would expect, this most often corresponds to the Long Method and Large Class code smells.

Why is This a Bad Thing?

A key idea of "good" object oriented design is that a class or method should do the smallest possible useful thing (Metz). A class or method which restricts itself to one responsibility can be easily reused, understood and tested. A large class or method usually implies the opposite — that it is doing too much, and is thus harder to reuse, understand and test.

Birth of a Fat Controller

Let's consider an example. We are building an application and have decided to roll our own authentication, like in the Hartl Rails tutorial. In our SessionsController's #create action, we have something like this:

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user
    else
      render 'new'
    end
  end

The test for this looks as follows:

describe SessionsController do
  let(:user) { FactoryGirl.create(:user) }

  describe "POST #create" do
    let(:email) { user.email }
    let(:password) { user.password }

    def do_create
      put :create, session: { email: email, password: password }
    end

    before { do_create }

    context 'valid email and password' do
      it { is_expected.to redirect_to(user) }
    end

    context 'invalid email/password' do
      let(:password) { 'wrong password' }
      it { is_expected.to render_template(:new) }
    end
  end
end

As our application grows in popularity, we realize that if someone wanted to keep guessing a user's password, there's no way to prevent them. Therefore, we decide to implement a "lock-out" feature. A lock-out feature works by setting a hard limit on the number of failed attempts and preventing a user from logging in when this limit is reached.

The way we'll choose to implement this feature is by adding a new integer column failed_attempts with a default of 0 to our User table. Whenever a given authentication attempt fails, we'll increment this column. When an authentication attempt succeeds, we'll check that the value in the failed_attempts column doesn't exceed a maximum value, and reset it to 0 if it doesn't. Here's how we could potentially code it:

  class SessionsController < ApplicationController
    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user && user.login_allowed? &&
user.authenticate(params[:session][:password])
        log_in user
        user.update(failed_attempts: 0)
        redirect_to user
      else
        if user
          user.increment!(failed_attempts)
          flash.now[:error] = "You are locked!" unless user.login_allowed?
        end
        render 'new'
      end
    end
  end

We'd implement #login_allowed? in the User class as follows:

  class User < ActiveRecord::Base
    MAX_LOGIN_ATTEMPTS = 3

    def login_allowed?
      failed_attempts < MAX_LOGIN_ATTEMPTS
    end
  end

In RSpec, the feature test for this would look like:

...
context 'a hacker is trying to brute force guess a password' do
  let(:user) { FactoryGirl.create(:user) }
  before do
    User::MAX_LOGIN_ATTEMPTS.times do
      fill_in "Email",    with: user.email
      fill_in "Password", with: ''
      click_button "Sign in"
    end
  end

  it { is_expected.to have_content('You are locked') }
end
...

The sessions controller test would also have to be updated to ensure that the failed_attempts column is incremented or reset, which will be left as an exercise.

When we first wrote the spec for SessionsController, we saw that its primary responsibility was to authenticate an email/password combination, and either redirect to the user's home page or render the sign-in form. SessionsController's knowledge of the User model was limited to .find_by and #authenticate.

However, with our addition above, we see that SessionsController now has the additional task of managing the failed_attempts column on User. Namely, if authentication succeeds, it has to reset failed_attempts, and if authentication fails, it increments it. It also needs knowledge of User#login_allowed?. This additional responsibility will also be reflected in the SessionsController spec.

For the sake of brevity, this feature has been implemented sparsely. If authentication requirements for your application were limited, leaving the code in your controller might suffice. That being said, here are a few examples of how we can add to this in a real world application:

  1. Notify the user that their account has been locked — this could possibly include the generation of a secure unlock or password reset token,
  2. On the last attempt, notify the user that their account will be locked if authentication fails, and
  3. Add an expiry time to the lockout — e.g. the account will be automatically unlocked after 24 hours.

The more we add to the controller and model, the harder it will be to understand and eventually change them.

Moving Responsibility Out of the Controller

We can simplify SessionsController by leveraging the fact that we're using an object oriented language. What if there was another object whose sole responsibility it was to perform user authentication?

If this object named AuthenticateUser existed, we could say something like this in our controller:

  def create
    result = AuthenticateUser.call(params[:session])
    if result.success?
      log_in result.user
      redirect_to result.user
    else
      flash.now[:error] = result.error_message
      render 'new'
    end
  end

This leaves our controller looking much like it did before we decided to add the lock-out feature. Our controller no longer cares about what 'authentication' entails — just that it was successful, has an associated user, and an error message. This means that if we decide to add capabilities to our authentication, the controller won't have to be changed. Moreover, our controller spec also becomes simplified because we only have to ensure that the AuthenticateUser class receives the .call message with the right params.

What would AuthenticateUser look like?

class AuthenticateUser
  attr_reader :success, :error_message, :email, :password

  def self.call(*args)
    new(*args).call
  end

  def initialize(session_params)
    @email = session_params.fetch(:email)
    @password = session_params.fetch(:password)
  end

  def call
    if authentication_successful?
      user.update(failed_attempts: 0)
      @success = true
    else
      @success = false
      handle_authentication_failure
    end
    result
  end

  private

  def authentication_successful?
    return false unless user
    user.login_allowed? && user.authenticate(password)
  end

  def handle_authentication_failure
    return unless user
    user.increment!(:failed_attempts)
    @error_message = 'You are locked' unless user.login_allowed?
  end

  def result
    OpenStruct.new(success?: success, error_message: error_message, user: user)
  end

  def user
    @user ||= User.find_by(email: email.downcase)
  end
end

A few things to note from the AuthenticateUser class above:

  1. Since we want our SessionsController to be able to say AuthenticateUser.call(...), we have to define the self.call class method. This class method instantiates an object and then sends #call to this instance with new(*args).call,
  2. In our controller code, we assign the return value of AuthenticateUser.call(...) to a local variable called result. The return value of the #call method is an OpenStruct. Since the result is an instance of OpenStruct, it allows us to query it with messages such as success?, user & error_message.

Our test for AuthenticateUser will be very similar to that of SessionsController when we implemented the lock-out feature. It will test authentication for valid and invalid cases, ensure that failed_attempts is set correctly and check the return value.

Placing AuthenticateUser in the File Structure

One of the benefits of having a set of interactors in your application is that they can tell you at a glance what your application 'does'. A common place to put your interactors is in the app/interactors/ directory. They can also be placed in the app/services/ directory.

Adding More Features

Now, let's say we've implemented this and a few months later we realize we're spending too much time manually unlocking user accounts. So, we decide to add a feature through which a user whose account gets locked can unlock it through an external channel like email, in a similar way to how they reset their password.

We have two options here. One is to add the requisite code to AuthenticateUser, and the other is to define a new object which will take over the responsibility for generating an unlock token,sending a notification email to the user, and call this object from AuthenticateUser. The second option could look something like this:

  class AuthenticateUser
    ...
    def call
      if authentication_successful?
        ...
      else
        ...
        SendUnlockInstructions.call(user)
      end
    end

The guiding factor as to which one of these two options you should choose depends on the amount of code smell caused by adding this new feature directly in AuthenticateUser. It can be helpful to first add the needed code to AuthenticateUser while getting your feature specs to pass, and then assess whether extracting to a second class would make things clearer.

Automatic Code Smell Detection

You've now seen how to use an interactor to reduce code bloat in a Rails controller. The next step would be to identify where the bloat exists in your own codebase and whether you can potentially use the interactor pattern to clean it up.

Rubocop and Reek are two great tools which you can use to identify bloat in your codebase.

It can be useful to set up your local development environment with overcommit. It allows you to hook into git actions, e.g. git commit, and run a tool of your choice. To install overcommit and have it run Rubocop when you make any new commit, you need to:

  1. Add the overcommit gem to your Gemfile in the development group,
  2. Run overcommit --install, and
  3. Configure overcommit to run Rubocop before a commit is complete by modifying the .overcommit.yml file. PreCommit: # Ignore all Overcommit default options ALL: enabled: false on_warn: fail

        # Enable explicitly each desired pre commit check
        RuboCop:
          enabled: true
          description: 'Analyzing with Rubocop'
          required_executable: 'rubocop'
    

You'll notice that we've set up overcommit to fail the commit if any offenses are found on Rubocop.

It can also be very helpful to run Rubocop as part of your Semaphore CI build setup, and configure it to stop the build if Rubocop reports any offenses. Since we've already set up overcommit, we can use it directly to achieve our goal:

overcommit --run

The above command, when included in your Semaphore CI build setup along with the configuration in .overcommit.yml, will ensure that Rubocop is run. However, the caveat is that the --run flag assumes that all files in the repo have changed.

Conclusion

In this tutorial, we started off with a brief discussion of code smells, with a focus on, code smells in the "bloaters" category. Then, we discussed how we'd apply the interactor pattern to address bloat in an authentication controller.

In most situations where there is code bloat, there is likely an object that's trying to make itself seen. The more you train yourself to find these objects, the more you can break down long methods and large classes into chunks which are easy to understand and test. This process is known as decomposition.

In addition to interactors, the following patterns are commonly used in Rails application to reduce code bloat:

  • Form Objects,
  • Query Objects,
  • Value Objects,
  • View Objects, and
  • Decorators.

If you have any questions and comments, feel free to leave them in the section below.

P.S. Semaphore is working on a book "The Ultimate Guide to BDD with Rails". Sign up to receive a FREE copy.

70b608b08f3cf3bb80c8a30033091804
Sid Krishnan

Sid Krishnan is a Ruby and Rails consultant. He is passionate about reducing technical debt, and helping product teams accelerate feature development.

on this tutorial so far.
User deleted author {{comment.createdAt}}

Edited on {{comment.updatedAt}}

Cancel

Sign In You must be logged in to comment.