Rails developers do their best to keep their controllers neat and trimmed down. You've probably heard the phrase "skinny controller, fat model" or even "skinny controller, skinny model".
The justification for smaller controllers comes down to a few things -
- Controllers are the entry point for several different route actions, which means that no one action should be cluttering up the file with its own logic. The controller is a common class for multiple actions.
- Logic can't be easily isolated and tested for every combination of input if it sits in the controller. The result is often a very long set of controller specs that's difficult to read, debug, and maintain.
- Generally keeping things simple, both visually and technically
Introducing Interactors
One of my personal favorite approaches to smaller controllers is the interactor design pattern, which lets you abstract away much of the core logic for any particular action.
An interactor is a simple, single-purpose object.
Interactors are used to encapsulate your application's business logic. Each interactor represents one thing that your application does.
- interactor gem
README
An interactor object will run a series of steps (your business logic) and report back on whether the action was successful or not. This is perfect for abstracting away logic because your controller action doesn't have to care about the implementation, it just wants to know whether those steps were successfully executed or not.
The interactor gem noted above is one of many ways to implement this design pattern. I use it in the examples below because it's extremely light-weight. In fact, I highly encourage you to read it yourself as it's only a few small files. If you're up to it, you could even implement it yourself if you're opposed to adding another dependency to your app.
An Example
My favorite use case to use interactors is for form submission validation.
Let's say we have a simple form that lets a user sign up for our app. They enter their first name, last name, and email address and hit "submit"
We'll want to validate a few things -
- First or Last name should not be blank
- Email should not be blank
- Email should not be taken by another existing user
- Email should be of a valid format (let's say x@y.z to keep it simple)
UsersController
Our controller action will receive the params and create the user
class UsersController < ApplicationController
def create
# Validate the form data and return if it is not correct
# We'll come back and fill in the ???? with the call to the
# interactor shortly.
unless ("????")
flash[:error] = "????"
redirect_to(new_users_path)
return
end
user = User.create!(user_params)
flash[:success] = "Woohoo, you created an account"
redirect_to(user_path(@ser))
end
private
def user_params
@user_params || = params.require(:user).permit(
:first_name,
:last_name,
:email
)
end
end
The Interactor
Below is the interactor that we could write to perform the series of validations on the data. It takes the params hash from the controller as an input and runs through the series of checks, failing along the way if any particular check fails.
class UsersCreateActionInteractor
include Interactor
def call
@params = context.params
case
when name_is_blank? then handle_blank_name
when email_is_blank? then handle_blank_email
when email_is_taken? then handle_email_is_taken
when email_format_invalid? then handle_email_format_invalid
else
handle_success
end
end
private
def name_is_blank?
@params[:first_name].blank? || @params[:last_name].blank?
end
def handle_blank_name
# Note: The interactor gem supports a short-hand notation for
# the below actions:
# > context.fail!(err_msg: "...")
context.err_msg = "Please fill in a name"
context.fail!
end
def email_is_blank?
@params[:email].blank?
end
def handle_blank_email
context.err_msg = "Please fill in an email"
context.fail!
end
def email_is_taken?
User.find_by_email(@params[:email]).any?
end
def handle_email_is_taken
context.err_msg = "That email already exists!"
context.fail!
end
def email_format_invalid?
(@params[:email] =~ /.*@.*\..*/i).blank?
end
def handle_email_format_invalid
context.err_msg = "Sorry, that doesn't look like an email address"
context.fail!
end
def handle_success
# n/a
end
end
A few things to note here:
- You need to include the interactor module in any particular class to make it an interactor
- The
#call
method is invoked when the interactor is called. - The context object is just a simple
OpenStruct
object provided to you by the interactor. (If you're not familiar withOpenStruct
, it's very similar to a hash but lets you access keys with the dot operator).Thecontext
object contains the arguments passed to the interactor (in this case the params), and you can further set any arbitrary values on it that will be available even after the interactor has finished running (context.foo = "bar"
). In this case we store the error message so our controller can access it and set it as the flash message. - If any particular action fails, we call the
#fail
! method, which will halt further execution and fail immediately. If #fail! is never called, the interactor implicitly marks itself as successful.
The beauty of this format is that all these checks are fully encapsulated inside a separate class. It's also scalable to add more checks easily as your number of validations grows, without cluttering up the main controller.
Tying it together
Now that we have our interactor in place, lets go back to the controller and see how we should call it
class UsersController < ApplicationController
def create
validator = UsersCreateActionInteractor.call(params: user_params)
unless validator.success?
flash[:error] = validator.err_msg
redirect_to(new_users_path)
return
end
user = User.create!(user_params)
flash[:success] = "Woohoo, you created an account"
redirect_to(user_path(@ser))
end
private
def user_params
@user_params || = params.require(:user).permit(
:first_name,
:last_name,
:email
)
end
end
We call our interactor using the class-level .call
method (the gem itself has some logic to create an instance and invoke the #call
method we defined earlier). The resulting object makes a few things available to us in the controller.
- The
#success?
method, which returnstrue
orfalse
- The
#failure?
method, which is just the opposite of the above - Any keys you might have set on the context inside the controller. In our case,
#err_msg
is available from the controller
Now your logic in the controller is vastly simplified, and relies on outsourcing all the core validations/checks to a separate interactor which does the heavy lifting.
Testing
Hopefully you can see how writing tests for our use case has now become easier!
In the spirit of high-low testing, we can confidently and cleanly test every combination of params inputs in our interactor specs. That way the controller has to only focus on testing one passing and one failing scenario, with the confidence that the interactor will correctly succeed or fail as needed.