Always Use RESTful Routes

  • June 14, 2021
  • Pete Whiting
  • 8 min read

by Dan Frenette

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.

Learn more about how The Gnar builds Ruby on Rails applications.

Interested in building with us?