Model Every State Change as CRUD on a Noun
A principle tutorial (P6) from the Beautiful Rails (Campfire) series. Derives, from first principles, why every verb you're tempted to bolt onto a controller — ban, reset, mute, switch-room — is really the create/update/destroy of a hidden noun. Shows the naive fat-controller-with-custom-verbs Rails you'd write first, then how naming the noun collapses the controller to a tiny resourceful set and keeps routes.rb flat. Grounded in Campfire's bans/join_codes/bot-keys controllers, scope module:/scope defaults:, and open-a-room-is-just-GET-#show, with the principle composing against P1 (fat model), P3 (security as shape), and P4/P5 (one renderer).
The principle: Any verb you're tempted to bolt onto a controller (ban, reset, mute, open-room) is really the create/update/destroy of a hidden noun — name that noun and the controller collapses to a tiny resourceful seven-actions, and the routes file stays flat.
① First principles: the verb you bolted on was a noun in disguise
You're building the admin side of a chat app. The asks come in as verbs, one a week. Let an admin ban a user. Let them regenerate the account's join code. Let them reset a bot's API key. Let a member mute a room. Each one is an action a button triggers, so each one feels like it wants a method on a controller. You reach for the tool Rails hands you for exactly this — the custom member route — and you write the honest first version:
# config/routes.rb
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
class UsersController < ApplicationController
before_action :ensure_can_administer, only: %i[ban unban reset_join_code reset_bot_key]
def ban
@user = User.find(params[:id])
@user.update!(status: :banned)
@user.sessions.delete_all
@user.messages.each(&:destroy)
redirect_to @user
end
def unban
@user = User.find(params[:id])
@user.update!(status: :active)
redirect_to @user
end
def reset_join_code
Current.account.update!(join_code: SecureRandom.alphanumeric(12))
redirect_to edit_account_url
end
# ...and next sprint: reset_bot_key, mute_room, transfer_ownership...
end
This is the naive version you'd vibe-code first, and it isn't a strawman — it's the path of least resistance. The request landed on a user, so the action goes on UsersController; Rails offers member do post :ban, so you take it. It works. Ship it.
Now run the tape forward six months. UsersController has fifteen actions. routes.rb is a thicket of member do blocks. ensure_can_administer is copy-pasted into a growing only: array that someone will eventually forget to extend — and the day they add reset_bot_key to the actions but not to the guard, you've shipped an open admin endpoint and nothing errored. The controller now knows about users, accounts, sessions, messages, and bots. It has stopped being about anything.
Here's the question that dissolves the whole mess. Stop asking "what action is this?" and ask "what is the thing whose lifecycle is changing?"
- Banning a user isn't a verb on a user — it's the creation of a
Ban. Unbanning is its destruction. - Regenerating a join code isn't a verb on an account — it's the creation of a fresh
JoinCode(aPOST, because you're making a new one). - Resetting a bot key isn't a verb on a bot — it's the update of a
Key. - Muting a room isn't a verb at all — it's the update of your
Involvementto a quieter value.
Every one of those verbs was a noun in disguise. This is the move: find the noun. Once you name it, the verb stops being custom code and becomes one of the seven RESTful actions Rails already routes for free — create, update, destroy. No member do. No custom helper. No if action ==. The route is just resource :ban, only: %i[create destroy], and the controller it points at does exactly one CRUD operation.
This isn't a Campfire habit you have to take on faith — 37signals wrote it down as law. Fizzy (their open-source Kanban) ships a STYLE.md whose first rule is exactly this: "When an action doesn't map cleanly to a standard CRUD verb, we introduce a new resource rather than adding custom actions" — and the worked example is resources :cards do resource :closure end, the bad version being post :close / post :reopen (STYLE.md:138-153).
And because it does exactly one operation, it can be two lines. Why two lines? Because the work doesn't live in the controller at all — it lives on the model. We don't re-derive that here; the model owns the consequence is see P1: The Model Owns Its Consequences's whole thesis. The controller's only job is to translate one HTTP request into one method call on a noun:
# app/controllers/users/bans_controller.rb (the whole file)
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
The contrast: The naive UsersController#ban is an eight-line method that opens a session, deletes rows, loops over messages, and flips a status — orchestration smeared across the controller, growing every sprint. Here, create is @user.ban; redirect_to @user. The verb became a create on Ban; the eight lines of consequence moved onto the model where they belong (bans_controller.rb:5-8).
② The beauty in combination: thin controllers aren't discipline, they're a leftover
"Skinny controller, fat model" gets taught as a rule of willpower — be disciplined, don't put logic in controllers. That framing is backwards. Watch what happens when you hold "find the noun" together with three other principles, and you'll see the thin controller isn't something you impose. It's what's left over when every action is genuine CRUD and the noun's model carries the weight.
With P1 (the model owns the consequence). The controller can be two lines only because @user.ban is a complete checklist on the model — open a transaction, snapshot the banned IPs into durable rows, kick live connections, queue content removal, flip the status. The controller doesn't shrink because you held back; it shrinks because there's genuinely nothing left for it to do once the noun owns its own creation. Find the noun, and P1 has somewhere to put the weight.
With P3 (security is the shape of your data access). A resourceful action authorizes almost as a byproduct. When the route nests the noun under the user and the lookup loads through the current user, the leaking version becomes one you can't type — that's see P3: authorization-by-association. And two route-level conventions make it cheaper still: scope module: files the controller in a folder mirroring the URL, and scope defaults: { user_id: "me" } lets a path helper for "the current user's thing" need no argument at all. You don't sprinkle a guard onto each verb because there's one CRUD action to guard, and its load path already carries the authorization.
With P4/P5 (convention and one renderer). The deepest version of this principle is the verb that turns out to be a plain read. Opening a room and switching to a room feel like two verbs — open_room, switch_room — but they're the identical operation: GET on the room's #show. There's no "switch" code anywhere. The expensive sidebar rides in a permanent Turbo frame so it loads once and survives every switch (the mechanics are see F4: Turbo; the worldview is see P5: One Renderer), and the message list comes from one render path that picks between two pagination scopes. A verb you were about to invent was a read you already had.
The reader sees the payoff: you don't decide to keep controllers thin. You name the right noun, and thinness falls out — because P1 took the logic, P3 took the authorization, and P5 took the rendering. The controller is the small, boring seam that's left.
③ How 37signals did it
Three verbs, three nouns, three tiny controllers
Open Campfire's admin actions and you find no custom verbs anywhere. Banning is a Ban you create and destroy (bans_controller.rb:5-13, shown above). Regenerating a join code is a JoinCode you create — note it's a POST/create, because you're minting a new one (join_codes_controller.rb:1-8):
class Accounts::JoinCodesController < ApplicationController
before_action :ensure_can_administer
def create
Current.account.reset_join_code
redirect_to edit_account_url
end
end
Resetting a bot's API key is an update on a Key (keys_controller.rb:1-8):
class Accounts::Bots::KeysController < ApplicationController
before_action :ensure_can_administer
def update
User.active_bots.find(params[:bot_id]).reset_bot_key
redirect_to account_bots_url
end
end
Three controllers, each one CRUD action, each delegating to a single model method. The work is on the model — reset_join_code is update! join_code: generate_join_code on Account::Joinable (joinable.rb:8-10); ban is a three-step transaction on User::Bannable (bannable.rb:4-10). The controller never knows the steps. It translates HTTP to a method and redirects.
And this isn't one product's tic. Open Fizzy and you find the same move at a dozen turns: marking a card golden is the create/destroy of a Goldness whose whole controller is @card.gild / @card.ungild (cards/goldnesses_controller.rb:4-5,13-14); closing a card is the create/destroy of a Closure (cards/closures_controller.rb:4-6,15-16); triaging, watching, pinning, publishing — each a singular resource nested under the card (routes.rb:85-94). Two unrelated 37signals products — a chat app and a Kanban board, built years apart — reach for the identical shape whenever a verb shows up. That's what makes it doctrine and not a Campfire accident.
"Isn't
reset_join_codestill a custom verb — just moved to the model?" No, and the distinction is the whole point. On the controller, a custom verb means a custom route, a custom helper, and a growingonly:array — public surface area that drifts. On the model,reset_join_codeis a domain method behind a standardcreateroute; the verb is an implementation detail of the noun, not an HTTP endpoint. The noun's lifecycle (JoinCodegets created) is what the web sees. The model is allowed to have verbs; the controller is not.
The folder tree mirrors the route tree — without bending the URL
Look at how the routes are organized (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
end
end
end
Two conventions are doing quiet, load-bearing work. scope module: "users" says the controller for this lives in app/controllers/users/ — so Users::BansController sits in a folder that mirrors routes.rb one-to-one, while the URL stays the flat, RESTful /users/:user_id/ban. The folder structure and the route structure are the same shape; the URL doesn't pay for the organization.
And scope defaults: { user_id: "me" } injects a fixed param so user_profile_path generates /users/me/profile with no argument — "my profile" needs no id because the default supplies it, and the controller reads params[:user_id] == "me" through the current user anyway. The path helper for "the current user's thing" becomes argument-free, and the resourceful shape survives intact.
"
resource :ban(singular) instead ofresources?" Yes — singularresourcegenerates six routes (noindex), because a user has exactly one ban relationship; there's no list of bans to index under a user. Singular when the noun is one-per-context, plural when it's a collection. The router has a word for the cardinality, so you don't hand-write it.
The verb that was a read all along
Now the version that surprises people. Campfire has rooms you open, rooms you switch between, deep links that jump to a specific message. That's three verbs in the product spec. Count the controller actions that implement them (rooms_controller.rb:10-12):
def show
@messages = find_messages
end
One. Opening a room, switching rooms, and following a @:message_id deep link are all the same GET to #show (the deep link is just another route into it, routes.rb:73).
```ruby
module TrackedRoomVisit
extend ActiveSupport::Concern
included do
helper_method :last_room_visited
end
def remember_last_room_visited
cookies.permanent[:last_room] = @room.id
end
def last_room_visited
Current.user.rooms.find_by(id: cookies[:last_room]) || default_room
end
private
def default_room
Current.user.rooms.original
end
end
```
find_by (not find_by!) plus an association-scoped lookup means a stale or forged last_room cookie quietly falls back to the default room — the graceful-navigation half of the deliberate bang/no-bang split that's see P3: authorization-by-association. Authorization and fallback are the same expression.
The one thing #show does carry is choosing where in the conversation to open. A 9,000-message room shouldn't open at message #1. So find_messages picks between two pagination scopes on one render path (rooms_controller.rb:43-47):
if show_first_message = messages.find_by(id: params[:message_id])
@messages = messages.page_around(show_first_message) # deep link: 40 before + it + 40 after
else
@messages = messages.last_page # plain open: newest 40
end
Both branches hand the same view the same @messages; only the slice differs. And page_around reads like the sentence you'd say out loud (pagination.rb:21-23):
module Message::Pagination
extend ActiveSupport::Concern
PAGE_SIZE = 40
included do
scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :first_page, -> { ordered.first(PAGE_SIZE) }
scope :before, ->(message) { where("created_at < ?", message.created_at) }
scope :after, ->(message) { where("created_at > ?", message.created_at) }
scope :page_before, ->(message) { before(message).last_page }
scope :page_after, ->(message) { after(message).first_page }
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
end
class_methods do
def page_around(message)
page_before(message) + [ message ] + page_after(message)
end
def paged?
count > PAGE_SIZE
end
end
end
The contrast: The naive version writes an open_room action, a switch_room action, and a separate jump_to_message action with its own view — three endpoints for one read, each with its own Room.find (the unguarded kind that 500s on a deleted room), each re-deriving "where do I scroll to." Campfire has one #show, two composable scopes, and a fallback that's indistinguishable from its authorization. The verbs you were about to route were a read you already had.
The URL itself becomes a noun you CRUD
There's a stranger place this principle reaches, and Fizzy is where you see it: the URL's query state becomes a noun. A Kanban board is nothing but filtered views of cards — show me cards on these boards, assigned to me, tagged bug, not-yet-closed. The naive instinct is that a filter is just params you read off the request and turn straight into a query: Card.where(...) built inline in the controller, re-parsed on every request, never named. It works, and for a long time you don't notice that you've scattered the same query-building logic across index, show, the sidebar, and the export.
Fizzy names it instead. A Filter is a real record — its own table, with a creator_id and an account_id like any noun (filter.rb:1-5). And it's found-or-built exactly the way you'd find-or-build any record:
# app/models/filter.rb
class << self
def from_params(params)
find_by_params(params) || build(params)
end
end
That one line (filter.rb:8-10) is the whole move. The URL's query params hydrate into a domain object. If a Filter matching these params has been saved before, you get the persisted row back; otherwise you build a fresh, unsaved one. Either way the rest of the app holds a Filter object, not a bag of params — and a Filter knows how to turn itself into cards.
How does "matching these params" become a database lookup? You can't index a free-form hash, so Fizzy reduces the params to a single fingerprint and indexes that (filter/params.rb:20-37):
# app/models/filter/params.rb
class_methods do
def find_by_params(params)
find_by params_digest: digest_params(params)
end
def digest_params(params)
Digest::MD5.hexdigest normalize_params(params).to_json
end
def normalize_params(params)
params
.to_h
.compact_blank
.reject(&method(:default_value?))
.collect { |name, value| [ name, value.is_a?(Array) ? value.collect(&:to_s) : value.to_s ] }
.sort_by { |name, _| name.to_s }
.to_h
end
end
normalize_params is the careful part: it drops blanks and defaults, stringifies, and sorts by key so that ?board_ids[]=1&tag_ids[]=2 and ?tag_ids[]=2&board_ids[]=1 collapse to the same canonical hash — two URLs that mean the same query produce one digest. The MD5 of that canonical JSON becomes the lookup key. And the schema makes it a genuine identity: params_digest is null: false with a unique index on [creator_id, params_digest] (schema.rb), so each distinct filter a user expresses is exactly one row. A before_save recomputes the digest from the filter's own params so the stored key never drifts from its contents (filter/params.rb:40-42).
Now watch what falls out at the controller. Because the URL hydrated into a noun that owns its own query, the index action is one line (cards_controller.rb:11-13):
# app/controllers/cards_controller.rb
def index
set_page_and_extract_portion_from @filter.cards
end
@filter.cards — the controller asks the noun for its cards and paginates. It does not build a query, does not read fifteen params keys, does not branch on which filters are present. All of that lives on Filter#cards (filter.rb:19-42), a long, readable chain of conditional scopes that the controller never sees. The controller stayed CRUD-on-a-noun; it's just that the noun, this time, is the URL.
This is the same principle from a third angle. Banning was a verb that was really the create of a Ban. Opening a room was a verb that was really a read. And a query — the most ephemeral, throwaway thing in a web app, gone the moment the request ends — turns out to be a noun too: a Filter you can find, build, save, name, and re-find by its fingerprint. Query state itself becomes something you CRUD. Once you see that, there's almost nothing left in a web app that isn't the create, read, update, or destroy of some noun.
The whole picture
This is the throughline of the series in its routing-layer form — count the edge cases this line absorbs for free. resource :ban, only: %i[create destroy] absorbs the custom route, the custom helper, the forgotten-guard drift, and the question of where the controller file lives. The flat URL and the mirrored folder tree are the same shape because both sides asked the router the same question. The controller stays thin not because someone was disciplined, but because, once you found the noun, there was nothing left for it to hold.
And the noun even survives the case where the order is genuinely stored. Reordering a column on a Fizzy board does persist a position — that's real user intent, not something you can derive — yet sliding a column left is still CRUD on a noun: a left_position resource (resource :left_position, routes.rb:59-62) whose controller is one create calling @column.move_left (columns/left_positions_controller.rb:4-6). Derive when you can, store when it's intent — either way the web only ever sees the create of a noun. The verb never makes it onto the controller.
An aside on the derive-vs-store judgment. It's worth pausing on why the column gets a stored
positionwhen cards, elsewhere in Fizzy, get none. The rule isn't "always store" or "always derive" — it's: store when the value is genuine user intent that can't be recomputed, derive when it's a function of data you already have. A board's column order is a deliberate arrangement a human dragged into place; there's no formula that recovers it, so it's a realpositioninteger (column/positioned.rb:5). But — and this is the part that keeps the principle intact — storing the order does not turn reorder into a custom verb. Sliding a column left is still thecreateof aLeftPositionnoun (resource :left_position,routes.rb:59-62), whose controller is one line calling@column.move_left(columns/left_positions_controller.rb:4-6). Andmove_leftis a precise two-row transactional swap: read the left neighbor's position, write each column the other's value, both inside onetransaction doso the board never has two columns claiming the same slot (column/positioned.rb:44-52). Compare that to the card list, which has no position column at all because card order is derived from sort scopes — the exact counterpoint. So the judgment is real and it's worth making consciously, but it never escapes the principle: derived or stored, the reorder reaches the web as the create of a noun, and the controller stays a single CRUD line.
Key Takeaways — Patterns to Steal
- When a feature shows up as a verb — ban, regenerate, reset, mute — stop asking "what action is this?" and ask which noun's lifecycle is actually changing, because that verb is almost always the create, update, or destroy of a hidden record. The moment you reach for
member do post :ban, you've started turning the controller into a junk drawer whoseonly:guard array drifts until someone adds an action and forgets the guard, shipping an open admin endpoint that never errors. Campfire bans a user by creating aBan(resource :ban, thenUsers::BansController#createcalling@user.ban) — the verb just disappears into a plaincreate. - Once you've named the noun, let the controller be two lines: translate the HTTP request into one method call on that noun, then redirect — don't write the eight-line method that opens a session, deletes rows, loops over messages, and flips a status right there in the action. The work isn't being held back out of discipline; there's genuinely nothing left for the controller to do because the consequence lives on the model. That's why
Users::BansController#createis just@user.ban; redirect_to @user(bans_controller.rb:5-8) while the real three-step transaction sits onUser::Bannable#ban(bannable.rb:4-10). - A model is allowed to have verbs; a controller is not — so when
reset_join_codefeels like "still a custom verb," notice that on the controller a custom verb means a custom route, a custom helper, and a growing public surface that drifts, whereas on the model it's a domain method hiding behind a standardcreateroute. The web only ever sees the noun's lifecycle (aJoinCodegets minted), not the implementation verb. Campfire'sAccounts::JoinCodesController#createcallsCurrent.account.reset_join_code(join_codes_controller.rb:5), and that method is a one-lineupdate!onAccount::Joinable(joinable.rb:8-10). - When you want your controller files organized into folders but don't want the URLs to inherit a
/users/something/users/...mess, reach forscope module:rather than renaming controllers or bending the path. It tells Rails the controller lives in a subfolder while the URL stays the flat, RESTful one. Campfire wraps its nested user resources inscope module: "users"(routes.rb:38) soUsers::BansController(routes.rb:40) sits inapp/controllers/users/yet the route stays/users/:user_id/ban— the folder tree and the route tree are the same shape, and the URL pays nothing for the organization. - Reach for singular
resource(not pluralresources) the moment the noun is one-per-context, because the router has a word for cardinality and you shouldn't hand-encode it. A user has exactly one ban relationship, soresource :ban(routes.rb:40) generates six routes with noindex— there's no list of bans to index under a user. Plural when it's a collection, singular when it's one-of-a-kind; the difference spares you a meaninglessindexroute and the confusion of pluralizing something that's only ever one. - The deepest version of "find the noun" is catching the verb that's secretly a read: opening a room, switching rooms, and following a deep link all feel like distinct actions, but they're the identical
GETto#showof a different record. Don't writeopen_room,switch_room, andjump_to_messageas three endpoints each re-deriving where to scroll — there's no switching code because switching rooms is just reading a different room. Campfire'sRoomsController#showis one line,@messages = find_messages(rooms_controller.rb:10-12), and the deep link is merely another route into it (routes.rb:73). - Let the association-scoped lookup do double duty as both your authorization and your graceful fallback, instead of writing a separate "is this room accessible?" check next to an unguarded
Room.findthat 500s on a deleted record. Loading through the current user means an inaccessible or stale cookie simply isn't found, and pairingfind_by(not the bang version) with|| default_roomturns that miss into a quiet fallback. Campfire does exactly this withCurrent.user.rooms.find_by(id: cookies[:last_room]) || default_room(tracked_room_visit.rb:13) — authorization and graceful navigation are the same expression.