Controllers & Routing: The Layer That Should Almost Disappear
Foundations F2. The mechanics of how an HTTP request finds Ruby and stays thin — routes as a sitemap, resourceful CRUD, before_action guards, strong params, controller inheritance — grounded in Campfire's verb-as-noun controllers, authorization-by-association, the secure-by-default auth vocabulary, and the bot path that IS the human path. Reference-flavored: mechanics + the 37signals way + pointers to P3, P6, P7.
You've read the model (see F1: The Rails Model). You know the truth lives there. Now a request comes in — POST /rooms/8/messages — and something has to turn that HTTP into Ruby. That something is the controller, and the question this whole tutorial answers is: how little can it do?
Here's the version you'd vibe-code first. You need to ban a user, so you reach for the controller, because that's where the request lands. You add a ban action. Then you need to reset a join code, so you add reset_join_code. Then reset a bot key — reset_bot_key. Each one needs a custom route and a guard, so the routes file and the controller both start to bloat:
# config/routes.rb — the thicket forming
resources :users do
member do
post :ban
post :unban
post :reset_join_code # ...wait, that's not even about a user
end
end
# app/controllers/users_controller.rb — fifteen actions and counting
class UsersController < ApplicationController
before_action :require_admin, only: %i[ ban unban reset_join_code ] # copy-pasted onto each batch
def ban
@user = User.find(params[:id]) # find first...
head :forbidden unless current_user.admin? # ...then guard. Two statements.
@user.update!(status: "banned")
redirect_to @user
end
def reset_join_code
Account.first.update!(join_code: SecureRandom.alphanumeric)
redirect_to edit_account_path
end
# ...thirteen more verbs, each with its own route, its own guard, its own find
end
Within a year UsersController is a junk drawer of verbs, routes.rb is a forest of member do blocks, and require_admin is copy-pasted onto every batch of actions — until the day someone adds an action and forgets the guard, and now anyone can ban anyone.
Notice the shape of the bug: the
find and the authorization check are two separate statements, and the second one is the entire security boundary. The day you copy only the first line into a new action, the room leaks.
Campfire's UsersController doesn't have a ban action at all. None of these verbs exist as custom actions. Let's derive why — and watch the controller layer almost disappear.
The mechanics, from first principles
A controller is the translator between two worlds: HTTP (verbs, paths, params, status codes) and Ruby (objects and method calls). Three mechanisms do all the work.
routes.rb maps verb + path → controller#action. You could write each route by hand. But almost every URL in a CRUD app follows the same seven shapes — list, show, new-form, create, edit-form, update, destroy — so Rails generates all seven from one line:
resources :rooms # → 7 routes: index show new create edit update destroy
Its singular sibling generates six (no index, because there's only one):
resource :session # one-per-context noun: a user has ONE current session
Two modifiers control where the URL prefix lives versus where the controller file lives — and they need not match. scope module: "users" says "the controller class is Users::SomethingController, but don't put /users in the URL." scope defaults: { user_id: "me" } injects a fixed param so a path helper needs no argument. And direct defines a custom named URL helper for the cases the resourceful routes don't cover.
An action is just a method, and rendering is implicit. A show method that ends without an explicit render renders show.html.erb. That's why Campfire's edit action (see F3: Views, Partials & Helpers for the swap it triggers) can be literally empty — the convention picks the template by the action's name.
before_action factors shared setup and guards, running in declaration order before the action. This is where "find the record" and "check permission" live — and the central lesson of this tutorial is what happens when those two stop being separate statements.
params is untrusted input. Anything the client sends is in params, including fields you never want written. So params.require(:message).permit(:body, :attachment) is an explicit allow-list — strong params — and the naive instinct (params.permit!, permit-everything) is how a client sets admin: true on a record you didn't mean to expose.
Controller inheritance lets a subclass reuse a parent's action via super, overriding only the one seam that differs — the same way an STI subclass overrides one method (see F1: STI rooms).
The 37signals way
Every verb is CRUD on a noun
Here is Campfire's entire ban controller (users/bans_controller.rb:1-19):
class Users::BansController < ApplicationController
before_action :ensure_can_administer
before_action :set_user
def create
@user.ban
redirect_to @user
end
def destroy
@user.unban
redirect_to @user
end
private
def set_user
@user = User.find(params[:user_id])
end
end
Read what happened to the verb. There is no ban action. Banning a user is the creation of a Ban. Unbanning is its destruction. The custom verb dissolved into standard CRUD on a noun called Ban, and the two real actions collapsed to a single line each — each one just translates an HTTP request into a single method call on the model. The transaction, the IP harvesting, the content removal — all of that lives on @user.ban (see C3: The Ban Arc unpacks that model method). The controller's whole job is: receive the request, call the verb, redirect.
Fizzy does the same in a different product: closing a card is the creation of a Closure and reopening is its destruction — cards/closures_controller.rb:1-23 is a controller whose create is @card.close and whose destroy is @card.reopen, the verb dissolved into standard CRUD on a noun. The verb-as-noun reflex isn't a Campfire habit; it's how 37signals reach for state changes across unrelated apps.
The same move repeats across the app: regenerating a join code is a create on a join_code resource (accounts/join_codes_controller.rb); resetting a bot key is an update on a key resource (accounts/bots/keys_controller.rb). And the routes file stays flat because each noun gets a tiny resourceful route instead of a custom verb (routes.rb:40):
resource :ban, only: %i[ create destroy ]
"Isn't
POST /users/8/bansimpler than inventing aBanmodel just to delete it again?" It feels simpler for exactly one action. But a custom verb needs a custom route, a custom path helper, and its own guard — and the next verb needs three more. Naming the noun gives you the route, the helper, and a thin standard controller for free, and it forces the question "what record am I actually creating here?" — which is usually a row you wanted anyway (aBanyou can look up by IP later). This is the deep idea of see P6: Model Every State Change as CRUD on a Noun; here just steal the reflex: when you want to add a verb to a controller, find the noun instead.
Now the routing tricks that keep the file flat (routes.rb:37-52):
resources :users, only: :show do
scope module: "users" do
resource :avatar, only: %i[ show destroy ]
resource :ban, only: %i[ create destroy ]
scope defaults: { user_id: "me" } do
resource :sidebar, only: :show
resource :profile
resources :push_subscriptions do
scope module: "push_subscriptions" do
resources :test_notifications, only: :create
end
end
end
end
end
scope module: "users" is why the controller for /users/8/ban is Users::BansController living at app/controllers/users/bans_controller.rb — the controller folder tree mirrors the route tree one-to-one, but the URL stays /users/8/ban, not /users/users/8/ban. And scope defaults: { user_id: "me" } is a quiet gem: it makes user_profile_path generate /users/me/profile with no argument — the path helper for "the current user's profile" needs nothing passed to it, because "me" is baked into the route. The direct :fresh_user_avatar helper (routes.rb:58-60) does the cache-busting half of this story — that's see F6: Caching's "change the content, change the URL."
Fizzy completes the other half of this pair with resolve: routes.rb:226-241 teaches a Comment, a Notification, and an Event how to become a URL (resolve "Comment" builds the card path plus a #dom_id anchor; resolve "Notification" and resolve "Event" fall through to polymorphic_url), so a view or mailer writes link_to comment with no per-type case. The record knows how to address itself — direct and resolve are the two halves of the same 37signals routing move.
Authorization is the load, not a guard after it
Remember the naive bug — find on one line, head :forbidden unless ... on the next, the security boundary being the second statement you can forget to copy. Campfire makes that bug unwriteable. Look at how a sub-resource finds its room (room_scoped.rb:8-12):
def set_room
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
@room = @membership.room
end
There is no Room.find(params[:room_id]) anywhere.
The lookup goes through
Current.user.memberships — so a room the user doesn't belong to simply does not exist from their vantage point. find_by! raises RecordNotFound → a 404. There is no separate permission check because the query itself is the permission check. You cannot copy "just the find line" into a new controller and create a leak, because there is no leaking version to copy — the only find available is already scoped to the user.
Fizzy's card_scoped.rb:9-11 is byte-for-byte the same shape — @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) — and its top-level cards_controller.rb:54-59 loads Current.user.boards.find / Current.user.accessible_cards.find_by! the same way. When a chat app and a Kanban board, built years apart, reach the identical load-through-the-user line, the shape stops being an idiom and becomes the 37signals way.
Notice the deliberate bang. Sub-resources use find_by! — a hard 404, because an API consumer guessing ids deserves nothing. But top-level human navigation uses the gentler find_by + redirect (rooms_controller.rb:22-28):
def set_room
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
@room = room
else
redirect_to root_url, alert: "Room not found or inaccessible"
end
end
A human who clicks a stale link to a room they left gets a friendly bounce to the root, not a 404 wall. Same security shape (load through Current.user), two different failure manners chosen on purpose. The full worldview — security is the shape of your data access — is see P3: Security Is the Shape of Your Data Access; here the mechanic to internalize is: load every record through the current user — the IDOR you cannot type.
The contrast: the naive version is @room = Room.find(...) then head :forbidden unless @room.users.include?(Current.user) — two statements, the second forgettable. Campfire folds the boundary into the load, so forgetting it is impossible: there's nothing to forget.
Authentication as a vocabulary of class macros
Where does "you must be logged in" get enforced? Not with before_action :require_login sprinkled per-controller. It's a concern that installs the guard on the base class and then teaches the app a small vocabulary of opt-outs (authentication.rb:5-26):
included do
before_action :require_authentication
before_action :deny_bots
helper_method :signed_in?
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
def allow_bot_access(**options)
skip_before_action :deny_bots, **options
end
def require_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
before_action :restore_authentication, :redirect_signed_in_user_to_root, **options
end
end
private
def signed_in?
Current.user.present?
end
def require_authentication
restore_authentication || bot_authentication || request_authentication
end
end
The included do block runs in the context of the host class — here, ApplicationController (application_controller.rb:2 lists Authentication in its include line, the same table-of-contents idea as the models, see F7: Concerns as a Mechanism). So every controller in the app requires a session and denies bots by default, and a controller opts out by name: allow_unauthenticated_access, allow_bot_access. The unsafe state — forgetting to protect an action — is impossible to reach, because the protection is the default and you have to ask to remove it, in words that read as intent.
Fizzy reaches for the identical anatomy — concerns/authentication.rb:4-31 — an included do that installs before_action :require_authentication closed-by-default, then a class_methods do exposing the same opt-out macros (allow_unauthenticated_access, require_unauthenticated_access) with **options passthrough for only:/except:. Two unrelated 37signals products declare security posture in the same three readable lines, so this is the house convention, not a Campfire idiom.
Look at the conditional CSRF, too: protect_from_forgery unless: -> { authenticated_by.bot_key? }. A client authenticated by a bot key in the URL has no cookie and therefore no forgery surface, so CSRF protection switches off for exactly that path — derived from how you authenticated, not hardcoded. The strategy chain is just an || in priority order (authentication.rb:33-35): restore_authentication || bot_authentication || request_authentication. Adding a fourth method is adding one || term, not surgery on a nested if.
One predicate guards every write
The "can this person edit this?" decision lives in exactly one place (user/role.rb:8-10):
def can_administer?(record = nil)
administrator? || self == record&.creator || record&.new_record?
end
Read it as a sentence: you can administer a record if you're an admin, OR you created it, OR it doesn't exist yet. That one method is wired into the messages controller with a single before_action (messages_controller.rb:6, 53-55):
before_action :ensure_can_administer, only: %i[ edit update destroy ]
def ensure_can_administer
head :forbidden unless Current.user.can_administer?(@message)
end
One predicate, reused at every write across the app — "you can edit what you sent" expressed once. And here's the polymorphic payoff: a direct-message room wants everyone in it to be able to administer it, so Rooms::DirectsController (a subclass of RoomsController) overrides just that guard (directs_controller.rb:33-35):
# All users in a direct room can administer it
def ensure_can_administer
true
end
No if room.direct? branch anywhere in the parent. The subclass overrides the one seam (see P7: Polymorphism Over Conditionals).
Controller inheritance: the bot path IS the human path
A bot posting a message is almost-but-not-quite a human posting one. The naive move is a standalone Api::MessagesController that re-implements room lookup, message creation, and broadcasting — and drifts from the human path the first time you fix a bug in one and forget the other. Campfire instead subclasses (messages/by_bots_controller.rb:1-24):
class Messages::ByBotsController < MessagesController
allow_bot_access only: :create
def create
super
head :created, location: message_url(@message)
end
private
def message_params
if params[:attachment]
params.permit(:attachment)
else
reading(request.body) { |body| { body: body } }
end
end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end
super runs the entire human create — the same create_with_attachment!, the same broadcast_create, the same fan-out. The subclass overrides exactly two seams: message_params (a bot sends a raw request body, not a form field — reading is the small helper that reads it) and a head :created for the API caller. There is zero risk the bot path and the human path drift, because they are literally the same action — the bot path is the human path with one parameter-parsing override and one status code.
allow_bot_access only: :create is the one door opened in the otherwise bot-denying default. This is see P7: the bot path IS the human path; the mechanic here is super plus a seam override.
One query, two lists
A small recurring idiom worth stealing (accounts_controller.rb:6-7):
users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
Enumerable#partition splits one loaded set into two lists in one pass — no second query, no risk the two lists were scoped differently by hand. The code matches the thought: one set, split by a rule.
Which principles this serves
Controllers and routing are where three principles touch the request:
- see P6: Model Every State Change as CRUD on a Noun — the verb-as-noun reflex (
bans_controller.rb) andscope module:/scope defaults:that keep the routes flat. Find the noun, get a thin controller for free. - see P3: Security Is the Shape of Your Data Access — authorization-by-association (
room_scoped.rb:8-12), the secure-by-default auth vocabulary (authentication.rb), andcan_administer?guarding every write. The IDOR you cannot type. - see P7: Polymorphism Over Conditionals (the controller-inheritance half) —
ByBotsController < MessagesControllercallingsuper(by_bots_controller.rb), andRooms::DirectsControlleroverriding one guard. The branch was a missing subclass.
The whole point of this layer is that it almost disappears. Count the lines: Campfire's ban controller is two real lines, its bot controller is super plus a seam, its room lookup is one scoped query. Each absorbs an edge case — a leaked room, a drifted API path, a forgotten guard — that the naive fat controller hits in production. Count the edge cases this line absorbs for free, and the thinnest controllers turn out to be the ones doing the most.
Key Takeaways — Patterns to Steal
- When a feature arrives as a verb — ban, unban, reset, regenerate — stop before you add a
banaction to a controller and ask what noun is actually being born or destroyed, because that verb almost always wants to becreateordestroyon a resource. The naive path ismember do post :ban endplus adef banwith its own route, its own helper, and its own guard, and the next verb wants three more — until the controller is a junk drawer. Campfire bans a user by creating aBan:users/bans_controller.rb:5-8is justdef create; @user.ban; redirect_to @user; end, and the verb disappears into a plaincreate. - Remember that
resources :roomshands you all seven CRUD routes from one line, and its singular siblingresource :sessionhands you six — it dropsindexbecause a one-per-context noun like the current user's session has nothing to list. Don't hand-write the routes when the noun is one-per-context either; reach forresource(singular) rather than forcing a plural with an id you'll never use. You can see both inroutes.rb—resource :ban(routes.rb:40) andresource :profile(routes.rb:44) are singular precisely because there's exactly one per user. - Load every record through the current user's associations so the find itself is the authorization — there should be no
Room.find(params[:id])anywhere to copy. The naive shape is@room = Room.find(...)on one line andhead :forbidden unless @room.users.include?(Current.user)on the next, where the second statement is the whole security boundary and the day you forget to copy it the room leaks. Campfire'sroom_scoped.rb:10goes throughCurrent.user.memberships.find_by!(room_id: params[:room_id]), so a room you don't belong to simply doesn't exist from your vantage point — the IDOR you cannot type. - Make "you must be signed in" the default for the entire app by installing the guard on the base class through a concern, then teach the app a vocabulary of named opt-outs — a controller has to ask to be public. The naive approach sprinkles
before_action :require_loginper controller, and the unprotected action is the one someone forgets to annotate. Campfire'sauthentication.rb:5-7putsbefore_action :require_authenticationandbefore_action :deny_botsin theincluded doblock, andallow_unauthenticated_access/allow_bot_access(authentication.rb:14-20) are the words you use to open a single door. - Treat
paramsas an explicit allow-list every single time you write to a record: name exactly the fields you accept withrequire/permit. The naive shortcut isparams.permit!or passingparamsstraight intoupdate!, which is how a client quietly setsadmin: trueon a row you never meant to expose.messages_controller.rb:71isparams.require(:message).permit(:body, :attachment, :client_message_id)— the allow-list is the whole defense. - Express "can this person edit this?" exactly once as a predicate and wire it into every write with a single
before_action, instead of re-typing the creator check into edit, update, and destroy where the copies drift apart. The naive controller repeatshead :forbidden unless @message.creator == Current.userin three actions and three more next week. Campfire'suser/role.rb:8-10definescan_administer?asadministrator? || self == record&.creator || record&.new_record?, andmessages_controller.rb:6,53-55guards every write through that one method. - When a second path is almost-but-not-quite an existing action — a bot posting a message versus a human posting one — subclass the controller and call
super, overriding only the one seam that differs, so the two can never drift. The naive instinct is a standaloneApi::MessagesControllerthat re-implements room lookup, creation, and broadcasting, and silently diverges the first time you fix a bug in one and forget the other.Messages::ByBotsController < MessagesController(by_bots_controller.rb:1-7) callssuperto run the entire humancreate, overriding onlymessage_paramsto read a raw request body and adding ahead :created— the bot path is the human path.