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
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
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.
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 email@example.com to keep it simple)
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
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
#callmethod is invoked when the interactor is called.
- The context object is just a simple
OpenStructobject provided to you by the interactor. (If you're not familiar with
OpenStruct, it's very similar to a hash but lets you access keys with the dot operator).The
contextobject 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
We call our interactor using the class-level
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
.callmethod (the gem itself has some logic to create an instance and invoke the
#callmethod we defined earlier). The resulting object makes a few things available to us in the controller.
#success?method, which returns
#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_msgis 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.
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.