TL;DR
Using non-RESTful routes in your Rails application can lead to a lot of undue chaos and uncertainty. Don’t be afraid to break away from conventional patterns of mapping all of your controllers to models just for the sake of doing it when there are more expressive and straightforward ways to divvy up the responsibilities of your controllers.
Background
There’s a lot to be said for REST, RESTful routing, and all its benefits to developers and the internet. However, this post is about leveraging RESTful routing to improve code quality in Rails applications. Before we can discuss that, you must have a solid understanding of what RESTful routing is.
A RESTful route is a route that provides a mapping of HTTP verbs (get
, post
, put
, patch
, delete
) to the CRUD actions (create
, read
, update,
delete
) that are defined in our controllers. However, because Rails allows users to define routes in RESTful and non-RESTful ways, things can sometimes get out of hand when we try to create our own routing conventions. In this post, we’ll look at some of the pros and cons of using our own routing conventions, and I’ll show the best way I’ve found to create routes in Rails and what your code can look like when you do it that way.
Problem
In Rails, there are two main ways to define a route: with either the resource
or resources
keywords, which promotes RESTful routes (the kind this post is advocating for) or through any of the various HTTP verb helpers such as get,
post,
patch,
or delete
. The “advantage” to using a non-RESTful route through one of the HTTP verb helpers is that you’re no longer constrained to using just the RESTful actions of a rails controller.
For example, let’s say we’re building a group chat application similar to Slack or Discord, and we want to develop a feature to ban a user. Let’s also say that banning a user in our application is a relatively complicated process. When a user is banned, we need to update that user record in the database. Server owners and the banned user must be notified of the ban in an email. Finally, once a user is banned, all of their messages should be hidden.
First Example: Expanding Existing RESTful Routes
As our first pass at an implementation, we’ll build the ban functionality into the UsersController#update
action.
# config/routes.rb
Rails.application.routes.draw do
resources :users, only: [:create, :show, :update]
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def update
if user.update(user_params)
if user.status_previously_changed?(from: "active", to: "banned")
user.messages.hide_all!
UserBannedMailer.deliver_later(
user: user,
server_owner: user.server_owner,
)
end
flash[:success] = "User updated successfully!"
else
flash[:alert] = "There was an issue updating the user."
end
redirect_to user_path(user)
end
private
def user
@user ||= User.find(params[:id])
end
end
Second Example: Creating A Non-RESTful Route
Instead of having to create an entire form in the HTML to change a single attribute, followed by a new branch of logic in UsersController#update
for updating the user's status, I can separate the responsibilities of banning a user and updating a user by adding a non-restful action.
# config/routes.rb
Rails.application.routes.draw do
resources :users, only: [:create, :show, :update] do
put :ban
end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def update
if user.update(user_params)
flash[:success] = "User updated successfully!"
else
flash[:alert] = "There was an issue updating the user."
end
redirect_to user_path(user)
end
def ban
if user.ban && user.messages.hide_all
UserBannedMailer.deliver_later(user: user, server_owner: user.server_owner)
flash[:success] = "User banned successfully! Good riddance!"
else
flash[:alert] = "There was an issue banning the user. Contact support!"
end
redirect_to user_path(user)
end
private
def user
@_user ||= User.find(params[:id])
end
end
This code is a significant improvement, but the problem is it's difficult to scale this without negatively impacting the readability and organization of our code. If we need to add more features that require changes to users in the database, we'll have to continue adding them to the same controller, and the more code that gets added to the controller, the more likely it is to become challenging to read and maintain.
Let's say we receive another feature request to timeout a user. Putting a user in a timeout is a temporary ban that does not hide their messages or email the server owner. We could certainly build this within the existing UsersController#ban
action. However, we'd be right back where we started trying to keep our controller actions limited to a single responsibility.
We could create a timeout
action, but then that's adding more responsibility to the controller overall. For example, what if we need to show a list of all the users that have been banned or timed out? Based on where the code is right now, we'd likely create the actions needed to display those pages in this controller, as this is the place where timeouts are handled.
Additionally, the UsersController
is still responsible for all the things it usually would be. For example, something like an index
action for all the users on a given server would need to live here as well. This means that this class is likely (or at least liable) to grow in complexity even without the responsibilities of banning and timing out users.
Solution
Any time there are non-RESTful actions in your application, that’s probably a sign that you need to create a new controller, not a new action. Instead of our routes looking like they do in the sample above:
# config/routes.rb
Rails.application.routes.draw do
resources :users, only: [:create, :show, :update] do
put :ban
end
end
They’d look like this:
# config/routes
Rails.application.routes.draw do
resources :users, only: [:create, :show, :update] do
resources :bans, only: [:create]
end
end
Side note: Here, we can see an example of a feature of Rails I like to use: only
. I always use only
when writing routes because it lets me explicitly state the routes that will be in my controller (except for the rare case when I’m going to use all of them). This prevents more routes from being created than are necessary while also making developer resources such as rake routes
or http://localhost:3000/rails/info/routes
nice guides to all the things that can happen in your application. Some people like to use except
in these cases to save time, but I still prefer to be verbose because I find it easier to think about what is there than what isn’t.
# app/controllers/bans_controller.rb
class BansController < ApplicationController
def create
issue_ban_to_user
if user_ban_successful?
notify_user_and_server_owner
flash[:success] = "User banned successfully! Good riddance!"
else
flash[:alert] = "There was an issue banning the user. Contact support!"
end
redirect_to user_path(user)
end
private
def user
@_user ||= User.find(params[:user_id])
end
def issue_ban_to_user
ban_user
hide_all_user_messages!
end
def ban_user!
user.ban
end
def hide_all_user_messages
user.messages.hide_all
end
def user_ban_successful?
user.banned? && user.messages.all?(&:hidden?)
end
def notify_user_and_server_owner
UserBannedMailer.deliver_later(
user: user,
server_owner: user.server_owner,
)
end
end
class UsersController < ApplicationController
def update
if user.update(user_params)
flash[:success] = "User updated successfully!"
else
flash[:alert] = "There was an issue updating the user."
end
redirect_to user_path(user)
end
private
def user
@_user ||= User.find(params[:id])
end
end
You’ve probably noticed we’ve made some improvements from the three lines in the controller action before. Now, the code that bans a user and the code that hides their messages live inside private methods.
In the future, it might be deemed worthwhile to add these private methods into a service object to keep the controller readable and well-organized. While this post isn't about the benefits of service objects, I'm bringing it up as I see it as a similar change (or refactor) to the way we moved code from our UsersController
into the new BansController
.
Both of these changes are following what is called the Single Responsibility Principle. The Single Responsibility Principle espouses that when we start separating code into individual classes or files to reduce the number of responsibilities in a given class, those responsibilities become a lot easier to understand, and by extension, express through the code we're writing.
This implementation also sets up precedence to start developing the timeouts feature. Now that we already have a BansController
, it makes much more sense in our application to create a TimeoutsController
.
Outcome/Takeaways
Follow REST! Great things happen in your application when you write exclusively RESTful routes. If you have non-RESTful routes in your application already, moving them to a new RESTful controller makes for great small, contained pull requests that improve the organization of your app. If you like some of the ideas I've proposed in this post, but aren't sure if they're right for your application, check out this post on evaluating alternatives.
This post originally published on The Gnar Company blog.
Learn more about how The Gnar builds Ruby on Rails applications.
Top comments (0)