Jonathan Davies

Avoiding Custom Routes in Rails

I’m currently building an RSS Reader. One of the core functions is being able to bookmark a feed item to save it for later.

Let’s think of some ways we could approach this on the backend:

Item Update Action

class ItemsController < ApplicationController

  # Other Actions...

  def update
    @item = Item.find(params[:id)

    @item.update(item_params)
    redirect_to @item
  end

  private

  def item_params
    params.require(:item).permit(:bookmarked)
  end

end

On the face of it, this seems nice. It’s RESTful, we’re using one of Rails’ default controller actions, and just passing in either true or false to set the bookmarked attribute.

The problem is, what happens when I want to start updating other attributes on the item? e.g. mark as read / unread? Do I use this endpoint too? Or I want to start pinging third-party services. This controller actions could quickly become unwieldy and be responsible for a lot of scenarios.

Custom Controller Actions

I’ve seen this a lot:

resources :items, only: [:index, :show] do
  patch :mark_as_bookmarked, on: :member
  patch :mark_as_unbookmarked, on: :member
end

The telltale sign that this is a code smell: we’ve got an extra verb in there with ‘mark’. The difficult thing about this is, in isolation, it doesn’t seem too bad. You don’t have to create a new controller and actions are nicely nested in a single file. Great, right?

But you’ll blink and find your Routes swarming with these custom actions that often behave inconsistently from each other.

Here’s my preferred option:

Additional RESTful Routes

resources :items, only: [:index, :show] do
  resource :bookmark, only: [:create, :destroy]
end

The mental model for this is that we’re thinking about how what we want to do fits within the framework of REST. In this case we want to “create a bookmark” or “destroy a bookmark”.

class BookmarksController < ApplicationController

  def create
    @item = Item.find(params[:item_id])

    @item.update(bookmarked: true)
    redirect_to @item
  end

  def destroy
    @item = Item.find(params[:item_id])

    @item.update(bookmarked: false)
    redirect_to @item
  end

end

Here’s why I like it:

  • We’re staying within Rails’ RESTful approach and not having to make decisions on naming actions.
  • We’re not overloading our existing controllers with new actions.
  • As the app grows, we have a better environment to manage additional functionality (like pushing bookmarks to a third party service).

Your controllers don’t have to map 1:1 to your active record models (see: The Map is Not The Territory).

Further reading: