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:
- Each code smell has a descriptive name and definition, e.g. “Long Method”.
- 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:
- Notify the user that their account has been locked โ this could possibly include the generation of a secure unlock or password reset token,
- On the last attempt, notify the user that their account will be locked if authentication fails, and
- 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:
- Since we want our
SessionsController
to be able to sayAuthenticateUser.call(...)
, we have to define theself.call
class method. This class method instantiates an object and then sends#call
to this instance withnew(*args).call
, - 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 anOpenStruct
. Since the result is an instance ofOpenStruct
, it allows us to query it with messages such assuccess?
,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:
- Add the
overcommit
gem to your Gemfile in the development group, - Run
overcommit --install
, and - 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. Would you like to learn how to build sustainable Rails apps and ship more often? We’ve recently published an ebook covering just that โ “Rails Testing Handbook”. Learn more and download a free copy.