Model Every State Change as CRUD on a Noun

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).

verb-as-noun: every custom controller verb is CRUD on a hidden noun find the noun: the move that collapses a fat controller into a resourceful seven actions the two-line CRUD controller: HTTP-to-method translation, work lives on the model scope module: makes the controller folder tree mirror routes.rb without changing URLs scope defaults: { user_id: 'me' } makes a path helper argument-free open/switch a room is just GET #show — a read, not a verb one render path, two pagination scopes (last_page vs page_around) skinny controller is not discipline you impose — it's what's left when every action is genuine CRUD campfire by nityeshagarwal

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.

A square 1:1 technical teaching poster on an off-white (#FAF8F3) ground, in the…

① 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 (a POST, 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 Involvement to 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.

A square 1:1 hand-drawn Wait-But-Why style stick-figure sketch (Tim Urban aesth…

③ 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_code still 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 growing only: array — public surface area that drifts. On the model, reset_join_code is a domain method behind a standard create route; the verb is an implementation detail of the noun, not an HTTP endpoint. The noun's lifecycle (JoinCode gets 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 of resources?" Yes — singular resource generates six routes (no index), 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).

A square 1:1 technical flow diagram, same locked visual system as the rest of t…
```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 position when 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 real position integer (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 the create of a LeftPosition noun (resource :left_position, routes.rb:59-62), whose controller is one line calling @column.move_left (columns/left_positions_controller.rb:4-6). And move_left is a precise two-row transactional swap: read the left neighbor's position, write each column the other's value, both inside one transaction do so 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 whose only: 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 a Ban (resource :ban, then Users::BansController#create calling @user.ban) — the verb just disappears into a plain create.
  • 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#create is just @user.ban; redirect_to @user (bans_controller.rb:5-8) while the real three-step transaction sits on User::Bannable#ban (bannable.rb:4-10).
  • A model is allowed to have verbs; a controller is not — so when reset_join_code feels 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 standard create route. The web only ever sees the noun's lifecycle (a JoinCode gets minted), not the implementation verb. Campfire's Accounts::JoinCodesController#create calls Current.account.reset_join_code (join_codes_controller.rb:5), and that method is a one-line update! on Account::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 for scope 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 in scope module: "users" (routes.rb:38) so Users::BansController (routes.rb:40) sits in app/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 plural resources) 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, so resource :ban (routes.rb:40) generates six routes with no index — 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 meaningless index route 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 GET to #show of a different record. Don't write open_room, switch_room, and jump_to_message as three endpoints each re-deriving where to scroll — there's no switching code because switching rooms is just reading a different room. Campfire's RoomsController#show is 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.find that 500s on a deleted record. Loading through the current user means an inaccessible or stale cookie simply isn't found, and pairing find_by (not the bang version) with || default_room turns that miss into a quiet fallback. Campfire does exactly this with Current.user.rooms.find_by(id: cookies[:last_room]) || default_room (tracked_room_visit.rb:13) — authorization and graceful navigation are the same expression.