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.