The problem to be solved
As a Rails application grows, its controllers tend to accumulate actions beyond the seven RESTful actions (index
, show
, new
, edit
, create
, update
and destroy
). The more “custom” actions there are, the harder it can be to understand and work with the controller.
Here are three tactics I use to keep my Rails controllers organized.
First, a note about “skinny controllers, fat models”
The concept of “skinny controllers, fat models” is well-known in the Rails community at this point. For the sake of people who are new to Rails, I want to mention that one good way to keep controllers small is to put complex business logic in models rather than controllers. For more on this topic, I might suggest my posts “What is a Rails model?” and (since service objects are a common but I think misguided recommendation) “How I code without service objects“, as well as the original skinny controllers/fat models post by Jamis Buck.
But even if you put as much business logic as possible in models rather than controllers, you’re still left with some challenges regarding overly large controllers. Here are the main three tactics I use to address these challenges.
Tactic 1: extracting a “hidden resource”
Sometimes, when a controller collects too many custom actions, it’s a sign that there’s some “hidden resource” that’s waiting to be identified and abstracted.
For example, in the application that I work on at work, we have an Active Record model called Message
which exists so that internal employees who use the application can message each other. At one point we added the concept of a PTO request, which under the hood is really just a Message
, but created through a different UI than regular messages.
We could have put these PTO-request-related actions right inside the main MessagesController
but that would have made MessagesController
too big and muddy. Instead of just being about regular messages, MessagesController
would contain some code about regular messages, some code about PTO requests, and some code that relates to both things. So we didn’t want to do that.
Instead, we created a separate controller called PTORequestsController
. Even though we decided to have a resource called a PTO request, we didn’t create a separate Active Record model for that. The PTORequestsController
just uses the Message
model. Here’s what the controller looks like.
module Messaging
class PTORequestsController < ApplicationController
def new
@message = Message.new
end
def create
@message = Message.new(
message_params.merge(
sender: current_user,
recipients: [User.practice_administrator],
body: "PTO request from #{current_user.first_name} #{current_user.last_name}\n\n#{@message.body}"
)
)
if @message.save
@message.send_any_email_notifications(@message)
redirect_to submitted_messaging_pto_requests_path
else
render :new
end
end
def submitted
end
private
def message_params
params.require(:messaging_message).permit(:body)
end
end
end
Sometimes, like in this case, the “hidden resource” is pretty obvious from the start and so the original controller can just be left untouched. Other times (and probably more commonly), the original controller slowly grows over time and then a “last straw” moment prompts us to identify a hidden resource and move that resource to a new controller. Sometimes it’s easy to identify a hidden resource and sometimes it’s not.
Tactic 2: same resource, different “lenses”
To review the “hidden resource” example, we had one resource (messages) and then we added a new resource (PTO requests). Making the distinction between messages and PTO requests allowed us to think about the two resources separately and keep their code in separate places. This allowed “regular messages” and PTO requests to be thought about and worked with separately, lowering the overall cognitive cost of the code.
This second tactic applies to a different scenario. Sometimes we don’t want to have a different resource but rather we want to treat the same resource differently. I’ll give another example from the application that I work on at work.
In this application, which is an application for running a medical clinic, we have the concept of an appointment, which can have different meanings in different contexts. For example, in a scheduling context, we care about what time the appointment is for. In a clinical context, we care about the notes the doctor makes regarding the patient’s condition. In a billing context, we care about the charges associated with the appointment.
Early in the application’s life, it was okay for there just to be one single AppointmentsController
. But over time AppointmentsController
started to get cluttered and harder to understand. So we added a couple new controllers, Billing::AppointmentsController
and Chart::AppointmentsController
, so that each of these concerns could be dealt with separately. As I’m writing this post, I even realize that it would probably be smart for us to rename AppointmentsController
to Schedule::AppointmentsController
because almost everything that’s in AppointmentsController
is related to scheduling.
Unlike the case of messages and PTO requests, the idea here is not to come up with a new resource but rather to look at the same resource through different lenses. There’s no separate model called Billing::AppointmentsController
or Chart::AppointmentsController
. The benefit comes from being able to have separate places to deal with separate contexts for the same model.
Tactic 3: dealing with collections separately
This one is a simple one but useful and common enough to be worth mentioning. Sometimes I end up with controllers with actions like bulk_create
, bulk_update
, etc. in addition to the regular create
and update
actions. In this case I often create a “collections” controller.
For example, in my application I have a Billing::InsurancePaymentsController
and also a Billing::InsurancePaymentCollectionsController
. Here’s what the latter controller looks like.
module Billing
class InsurancePaymentCollectionsController < ApplicationController
before_action { authorize %i(billing insurance_payment) }
def create
@insurance_deposit = InsuranceDeposit.find_by(id: params[:insurance_deposit_id])
if params[:file].present?
RemittanceFileParser.new(
content: params[:file].read,
insurance_deposit: @insurance_deposit
).perform
redirect_to new_billing_insurance_deposit_insurance_deposit_reconciliation_path(
insurance_deposit_id: @insurance_deposit.id
)
else
redirect_to request.referer
end
end
def destroy
InsurancePayment.where(id: params[:ids]).destroy_all
redirect_to request.referer
end
end
end
Takeaways
- Controllers often have a tendency to grow over time. When they do, they usually become hard to understand.
- It’s helpful to put as much business logic as possible in models rather than controllers.
- A large controller can sometimes be made smaller by extracting a “hidden resource” that uses the same Active Record model but clothed in a different idea.
- Another way that large controllers can sometimes be broken up is to think about looking at the same resource through different lenses.
- It can also sometimes be helpful to deal with “bulk actions” in a separate controller.
I also spend most of my development time on a Rails app for a behavioral health agency that originated 15 years ago, and I’ve just recently started reorganizing my controllers along similar lines. I have some ugly and embarrassing controllers that have just grown out of hand, little by little.
Some great advice here. Thanks!
Right on! This enumeration of three themes in RESTful resource design nails it.
The moment I begin considering add a controller action that is not one of the seven stock CRUD verbs, I consider whether there is a not-yet-designed (what you call “hidden”) resource. A frequent hidden resource is a registration. Rather than adding a “register” action to an existing resource, I’ll create a registration resource, with new and create.
Should update to use:
redirect_back :fallback_location => “some-path”
Instead of using `request.referer` directly.