REST-only controllers - Josh Brody REST-only controllers | Josh Brody
Back

REST-only controllers

A few months into a project, I watched a developer add a mark_complete action to a TasksController that already had archive, unarchive, assign, unassign, prioritize, move_up, move_down, and duplicate. The routes file had so many member blocks it looked like a DSL for avoiding REST.

Each custom action did one thing, took maybe five lines of code, and was perfectly reasonable on its own. But collectively they’d turned the controller into a junk drawer. The before_action filters had grown into a decision tree. The tests were full of post :archive, params: { id: task.id } mixed with patch :update, params: { id: task.id, task: { ... } }. Nobody could remember which actions were idempotent.

This is how most Rails controllers end up. You start with clean CRUD, then add “just one more action” until you have a god controller with fifteen public methods and a case statement in the authorize method.

I’ve seen worse. The codebase where someone decided custom actions were too messy, so they routed everything through update with a query param:

def update
  case params[:do]
  when "complete" then @task.complete!
  when "archive" then @task.archive!
  when "assign" then @task.assign_to!(params[:user_id])
  when "prioritize" then @task.prioritize!(params[:priority])
  else @task.update!(task_params)
  end
end

Now you’ve got one action doing five different things, no way to authorize them separately, and URLs like /tasks/123?do=archive. The routes file looks clean, but you’ve just moved the mess somewhere harder to find.

There’s a better way: stop adding custom actions. Turn those actions into resources.

The mental shift

Here’s a TasksController with custom actions:

class TasksController < ApplicationController
  before_action :set_task, except: [:index, :new, :create]

  def index; end
  def show; end
  def new; end
  def create; end
  def edit; end
  def update; end
  def destroy; end

  def complete
    @task.complete!
    redirect_to @task
  end

  def reopen
    @task.reopen!
    redirect_to @task
  end

  def archive
    @task.archive!
    redirect_to tasks_path
  end

  def unarchive
    @task.unarchive!
    redirect_to @task
  end

  # ... and on it goes
end

Routes:

resources :tasks do
  member do
    post :complete
    post :reopen
    post :archive
    post :unarchive
  end
end

Now here’s the same behavior with REST-only controllers:

class TasksController < ApplicationController
  # Just the standard seven actions
end

class Tasks::CompletionsController < ApplicationController
  def create
    @task = Current.user.tasks.find(params[:task_id])
    @task.complete!
  end

  def destroy
    @task = Current.user.tasks.find(params[:task_id])
    @task.reopen!
  end
end

class Tasks::ArchivalsController < ApplicationController
  def create
    @task = Current.user.tasks.find(params[:task_id])
    @task.archive!
  end

  def destroy
    @task = Current.user.tasks.find(params[:task_id])
    @task.unarchive!
  end
end

Routes:

resources :tasks do
  scope module: :tasks do
    resource :completion
    resource :archival
  end
end

The insight is that “complete” isn’t an action you do to a task—it’s a resource you create. A completion. When you complete a task, you’re creating its completion. When you reopen it, you’re destroying that completion.

Same with archival, publication, subscription, assignment, and every other “action” you’ve been adding to controllers. They’re all resources.

Why this matters

It’s not just aesthetics. REST-only controllers have practical benefits.

When I join a project, the first thing I open is config/routes.rb. It’s diagnostic. A routes file full of member do blocks and custom actions tells me exactly what’s waiting in the controllers: conditionals, god objects, authorization spaghetti. A routes file that’s just nested resources tells me the controllers are probably clean too. The routes file is a leading indicator for the whole codebase.

The controllers stay small. The Tasks::CompletionsController above is eight lines. It does one thing: manage the completion state of a task. There’s no room for it to grow into a mess because there’s nothing else it could do. You can’t add a prioritize action to it—that would be absurd. The constraint forces you to create a new controller for the next concept.

Authorization becomes obvious. When every controller handles one resource, you can authorize at the controller level without complex conditionals.

class Tasks::CompletionsController < ApplicationController
  before_action :ensure_can_complete_task

  def create
    @task.complete!
  end

  def destroy
    @task.reopen!
  end

  private

  def ensure_can_complete_task
    head :forbidden unless Current.user.can_complete?(@task)
  end
end

No case statement in your authorize method. No checking which action is being called. The controller handles completions, so you check completion permission. Done.

Routes become predictable. POST /tasks/123/completion creates a completion. DELETE /tasks/123/completion removes it. Anyone who knows REST can guess your URLs. You don’t need to document that “completing a task is a POST to /tasks/:id/complete” because that’s a custom action—you use the standard verbs on a standard resource.

Tests get consistent too. Every controller test uses the same HTTP verbs for the same purposes. post creates something. delete removes something. patch updates something. You’re not mixing post :archive with patch :update in the same test file.

Patterns

After doing this for a while, you start to see the same patterns everywhere.

Boolean states become singular resources

If something can be turned on or off, it’s a singular resource. Creating it turns it on; destroying it turns it off.

resource :completion     # complete/reopen
resource :archival       # archive/unarchive
resource :publication    # publish/unpublish
resource :subscription   # subscribe/unsubscribe
resource :pin            # pin/unpin
resource :lock           # lock/unlock

The controller is always the same shape:

class Posts::PublicationsController < ApplicationController
  def create
    @post.publish!
  end

  def destroy
    @post.unpublish!
  end
end

Use resource (singular) because there’s only one publication per post. The routes are /posts/:post_id/publication, not /posts/:post_id/publications.

Many-to-many relationships become plural resources

If a user can watch multiple posts, and a post can be watched by multiple users, that’s a plural resource.

resources :posts do
  scope module: :posts do
    resources :watches, only: [:create, :destroy]
  end
end
class Posts::WatchesController < ApplicationController
  def create
    @post.watches.create!(user: Current.user)
  end

  def destroy
    @post.watches.find_by!(user: Current.user).destroy!
  end
end

Or if you don’t want the intermediate model:

class Posts::WatchesController < ApplicationController
  def create
    @post.watch_by(Current.user)
  end

  def destroy
    @post.unwatch_by(Current.user)
  end
end

Either way, the controller is just managing the watch relationship.

Attribute updates become singular resources

Sometimes you want to update a single attribute with its own UI and permissions. Don’t route it through the main controller’s update—make it a resource.

resource :role, only: [:edit, :update]
class Users::RolesController < ApplicationController
  before_action :ensure_admin

  def edit
  end

  def update
    @user.update!(role: params[:role])
  end
end

This is cleaner than checking if params[:role].present? in UsersController#update and having different authorization rules for role changes versus profile changes.

Position changes become create-only resources

Moving something up or down in a list? That’s creating a new position.

resource :up_position, only: :create
resource :down_position, only: :create
class Items::UpPositionsController < ApplicationController
  def create
    @item.move_up!
  end
end

class Items::DownPositionsController < ApplicationController
  def create
    @item.move_down!
  end
end

You’re not updating the item—you’re creating an up-position or down-position for it. The naming might feel weird at first, but it’s consistent with REST. You’re always creating or destroying something.

Context-specific actions become namespaced controllers

Sometimes the same action has different behavior in different contexts. Archiving a message from the inbox versus archiving it from a thread view might have different side effects—one might mark the whole thread as read, the other might not.

namespace :inbox do
  resources :messages do
    scope module: :messages do
      resource :archival
    end
  end
end

namespace :threads do
  resources :messages do
    scope module: :messages do
      resource :archival
    end
  end
end
class Inbox::Messages::ArchivalsController < ApplicationController
  def create
    @message.archive!
    @message.thread.mark_as_read!
  end
end

class Threads::Messages::ArchivalsController < ApplicationController
  def create
    @message.archive!
  end
end

The namespace communicates intent. This isn’t just Messages::ArchivalsController—the context is right there in the class name. You can have different authorization, different side effects, different everything.

Common objections

“But now I have fifty controllers.”

Yes. And? Each one is ten lines long, does exactly one thing, and is trivial to understand. Would you rather have five controllers with 200 lines each?

The filesystem is free. Your ability to hold complexity in your head is not.

“The URLs look weird.”

Do they? POST /tasks/123/completion seems pretty clear. You’re creating a completion for task 123. Compare to POST /tasks/123/complete—why is that verb better than a noun?

If anything, the resource-based URLs are more RESTful. REST is about resources and representations, not about mapping domain verbs onto HTTP verbs.

“It’s more files to navigate.”

Your editor has fuzzy search. Cmd+P “completions controller” and you’re there. Meanwhile, finding the complete action in a 300-line TasksController requires scrolling or searching.

“Some actions don’t fit the create/destroy pattern.”

Usually they do if you think about it differently.

  • “Bump priority” becomes creating a priority_bump
  • “Send reminder” becomes creating a reminder
  • “Recalculate totals” becomes creating a recalculation
  • “Sync with external service” becomes creating a synchronization

If it really doesn’t fit—maybe it’s a pure query with no side effects—consider whether it belongs in a controller at all. Maybe it’s a view concern, or a separate endpoint that returns JSON for your frontend to handle.

“What about wizards or multi-step forms?”

Each step is a resource. Step one creates the draft. Step two updates it with more data (or creates a “step two completion”). The final step creates the actual record from the draft. Or use a form object—but that’s orthogonal to controller design.

The constraint that liberates

This approach feels restrictive at first. You want to add a quick action and instead you have to create a new controller, a new route, maybe a new view directory. It seems like ceremony.

But the ceremony is the point. The friction of creating a new controller makes you think about what you’re actually modeling. Is this really a new concept, or am I just being lazy? Often it is a new concept that deserves its own home.

The constraint also prevents the gradual accumulation of mess. You can’t add “just one more action” because there’s no place to put it. You have to create something new, and creating something new feels like a bigger decision than appending to something existing. That’s good. It should feel like a decision.

After a while, you stop thinking about it. Actions are resources. Every controller handles exactly one resource with exactly the standard seven actions (or fewer). The routes file is a clean tree of nested resources. Each controller is small enough to read in one screenful.

And you never have to look at a fifteen-action controller with a case statement in the authorization method again.

Stay in the loop

Occasional essays on design, tools, and the craft of building things. No spam, unsubscribe anytime.

Ambient weather

The background of this site reflects the current weather and time of day in Saint Paul. The orbs shift in color and behavior based on what's happening outside my window.

Learn more about how this works