Controllers & Routing: The Layer That Should Almost Disappear

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.

routes.rb as the sitemap: resources/resource, scope module:, scope defaults:, direct the action as a method; implicit rendering; before_action in declaration order strong params as the allow-list (params.require.permit) verb-as-noun controllers: every state change is CRUD on a hidden noun authorization-by-association: the load IS the security boundary find_by! (hard 404) vs find_by + redirect (friendly nav) authentication as a vocabulary of class macros, secure-by-default one predicate (can_administer?) guarding every write via one before_action controller inheritance + super: the bot path IS the human path partition: one query, two lists campfire by nityeshagarwal

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.

A technical 'before' diagram in a Wait-But-Why hand-sketched style, title at to… 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.

A teaching infographic titled 'routes.rb is the SITEMAP — and the controller fo…

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 destructioncards/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/ban simpler than inventing a Ban model 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 (a Ban you 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.rbthe 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.

A two-panel side-by-side technical comparison diagram, teaching-first, clean fl… 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.

A technical inheritance diagram, teaching-first, clean flat style on white back… 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:

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 ban action to a controller and ask what noun is actually being born or destroyed, because that verb almost always wants to be create or destroy on a resource. The naive path is member do post :ban end plus a def ban with 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 a Ban: users/bans_controller.rb:5-8 is just def create; @user.ban; redirect_to @user; end, and the verb disappears into a plain create.
  • Remember that resources :rooms hands you all seven CRUD routes from one line, and its singular sibling resource :session hands you six — it drops index because 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 for resource (singular) rather than forcing a plural with an id you'll never use. You can see both in routes.rbresource :ban (routes.rb:40) and resource :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 and head :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's room_scoped.rb:10 goes through Current.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_login per controller, and the unprotected action is the one someone forgets to annotate. Campfire's authentication.rb:5-7 puts before_action :require_authentication and before_action :deny_bots in the included do block, and allow_unauthenticated_access / allow_bot_access (authentication.rb:14-20) are the words you use to open a single door.
  • Treat params as an explicit allow-list every single time you write to a record: name exactly the fields you accept with require/permit. The naive shortcut is params.permit! or passing params straight into update!, which is how a client quietly sets admin: true on a row you never meant to expose. messages_controller.rb:71 is params.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 repeats head :forbidden unless @message.creator == Current.user in three actions and three more next week. Campfire's user/role.rb:8-10 defines can_administer? as administrator? || self == record&.creator || record&.new_record?, and messages_controller.rb:6,53-55 guards 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 standalone Api::MessagesController that 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) calls super to run the entire human create, overriding only message_params to read a raw request body and adding a head :created — the bot path is the human path.