agentleFS
mickzijdelmickzijdel/rails-toolkit/skills/rails-controllers/SKILL.md

rails-controllers

Skill2 starsChanged 8 days agoMITView on GitHub

Use when writing thin controllers with concerns, resource-oriented design, and REST patterns

  • Reads credentials

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 0 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    When you add a method to ApplicationController — or find one already bloated — apply three rules:

    line 34
  2. Very specific:

    When multiple controllers load the same parent resource, create a *Scoped concern that handles the loading and provides shared helpers:

    line 73
  3. Very specific:

    Turning a verb into a noun: When you reach for a custom action, name the *thing* the verb produces and route to its create/destroy:

    line 161
  4. Very specific:

    Put shared checks in the scoping concern (e.g.

    line 209
  5. Very specific:

    Use fresh_when with ETags based on the data being rendered.

    line 215
  6. Very specific:

    Use respond_to blocks for HTML, JSON, and Turbo Stream.

    line 230
  7. Very specific:

    Prefer human-readable values (?category=acting, slugs) over opaque ids where possible.

    line 322
  8. Very specific:

    Keep it to one line per action.

    line 348

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Rails Controller Patterns

Pattern 1: Thin ApplicationController with Concerns

The base controller is just a composition of concerns plus a few global settings. All logic lives in the included modules.

class ApplicationController < ActionController::Base
  include Authentication
  include Authorization
  include BlockSearchEngineIndexing
  include CurrentRequest, CurrentTimezone, SetPlatform
  include RequestForgeryProtection
  include TurboFlash, ViewTransitions

  etag { "v1" }
  stale_when_importmap_changes
  allow_browser versions: :modern
end

Don't let ApplicationController become a dumping ground. Because it's already a grab-bag, every new method feels free — "will one more method among 25 really hurt?" — and the file grows into a pile of unrelated helpers (current-user checks, default redirects, production?/staging? environment checks, www-canonicalisation, response-header setters, …) that nobody dares move or delete because they might be referenced from anywhere. That's not a crisis, but it's real cognitive load on every controller in the app.

When you add a method to ApplicationController — or find one already bloated — apply three rules:

  1. Group related methods and label each group with a comment. Similar methods should sit together, not be scattered, so the next reader can scan the file.
  2. Don't make a method globally accessible unless it needs to be. A method that backs a before_action running on every request earns its place here. A method used by only one or two controllers (out of 20+) does not — move it into a concern and include it only in the controllers that need it (see Pattern 2 and the *Scoped concerns below).
  3. Two or more related methods → extract them into a named concern. A cluster like CDN/cache header setters becomes app/controllers/concerns/akamai.rb, leaving a single include Akamai line. The behaviour stays available but is contained, named, and testable in isolation.
# Bad: a dumping ground — unrelated helpers, no grouping, all global
class ApplicationController < ActionController::Base
  def current_account; ...; end
  def set_cache_headers; response.headers["Cache-Control"] = ...; end
  def force_www; redirect_to "https://www.#{request.host}..." unless ...; end
  def production?; Rails.env.production?; end
  def purge_cdn(path); ...; end
  def redirect_to_dashboard; ...; end
  def staging?; Rails.env.staging?; end
  def set_surrogate_key(*keys); response.headers["Surrogate-Key"] = ...; end
  # ...20 more, in no particular order
end

# Good: each cluster extracted into a named concern, base stays a manifest
class ApplicationController < ActionController::Base
  include Authentication      # current_account & friends
  include CanonicalHost       # force_www, host canonicalisation
  include Akamai              # set_cache_headers, set_surrogate_key, purge_cdn
end

Pattern 2: Resource Scoping Concerns

When multiple controllers load the same parent resource, create a *Scoped concern that handles the loading and provides shared helpers:

# app/controllers/concerns/card_scoped.rb
module CardScoped
  extend ActiveSupport::Concern

  included do
    before_action :set_card, :set_board
  end

  private
    def set_card
      @card = Current.user.accessible_cards.find_by!(number: params[:card_id])
    end

    def set_board
      @board = @card.board
    end

    def render_card_replacement
      render turbo_stream: turbo_stream.replace(
        [ @card, :card_container ],
        partial: "cards/container",
        method: :morph,
        locals: { card: @card.reload }
      )
    end
end
class Cards::CommentsController < ApplicationController
  include CardScoped   # sets @card and @board via before_action

  before_action :set_comment, only: %i[ show edit update destroy ]
end

Pattern 3: Resource-Oriented Design (Non-CRUD as Nested Resources)

Actions like "close", "reopen", "watch" don't map to CRUD verbs. Don't add custom routes — model the state change as a singular resource nested under the parent: create = turn on, destroy = turn off.

# Bad: custom actions
resources :cards do
  post :close
  post :reopen
end

# Good: singular nested resources
resources :cards do
  scope module: :cards do
    resource :closure        # POST = close, DELETE = reopen
    resource :goldness       # POST = gild, DELETE = ungild
    resource :watch          # POST = watch, DELETE = unwatch
    resource :pin            # POST = pin, DELETE = unpin
    resource :publish        # POST = publish
  end
end
class Cards::ClosuresController < ApplicationController
  include CardScoped

  def create
    @card.close

    respond_to do |format|
      format.turbo_stream
      format.json { head :no_content }
    end
  end

  def destroy
    @card.reopen

    respond_to do |format|
      format.turbo_stream
      format.json { head :no_content }
    end
  end
end

Turning a verb into a noun. When you reach for a custom action, name the thing the verb produces and route to its create/destroy:

Tempting verb actionNoun resourceMaps to
POST cards/:id/closeresource :closureCards::ClosuresController#create (destroy = reopen)
POST posts/:id/archiveresource :archivePosts::ArchivesController#create (destroy = unarchive)
POST posts/:id/publishresource :publicationPosts::PublicationsController#create
POST users/:id/followresource :followUsers::FollowsController#create (destroy = unfollow)
POST pages/:id/visitresource :visitPages::VisitsController#create

The on/off pair (create/destroy) keeps the controller RESTful and the routes guessable. Reserve member/collection custom routes for genuinely actionless endpoints that don't represent a resource.


Pattern 4: Scoped Resource Loading with Authorization

Raw Card.find(params[:id]) bypasses access control. Always load resources through scoped associations that respect user permissions:

def set_card
  # accessible_cards respects board access permissions
  @card = Current.user.accessible_cards.find_by!(number: params[:id])
end

def set_user
  # scoped to current account and active users only
  @user = Current.account.users.active.find(params[:id])
end

Pattern 5: Permission Checks with before_action

Centralize authorization in before_action filters named ensure_permission_to_* that head :forbidden when denied:

class CardsController < ApplicationController
  before_action :set_card, only: %i[ show edit update destroy ]
  before_action :ensure_permission_to_administer_card, only: %i[ destroy ]

  private
    def ensure_permission_to_administer_card
      head :forbidden unless Current.user.can_administer_card?(@card)
    end
end

Put shared checks in the scoping concern (e.g. ensure_permission_to_admin_board in BoardScoped) so controllers just declare before_action :ensure_permission_to_admin_board.


Pattern 6: ETags for HTTP Caching

Use fresh_when with ETags based on the data being rendered; Rails returns 304 Not Modified when the client's cached version matches.

def index
  @columns = @board.columns.sorted
  fresh_when etag: @columns
end

Full treatment (multi-object ETags, stale?, global etag components): [[rails-performance]].


Pattern 7: Multiple Format Responses

Use respond_to blocks for HTML, JSON, and Turbo Stream. Conventions: HTML redirects, JSON returns status codes, Turbo Stream renders the matching .turbo_stream.erb template.

def create
  respond_to do |format|
    format.html do
      card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted")
      redirect_to card
    end

    format.json do
      card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
      head :created, location: card_path(card, format: :json)
    end
  end
end

def update
  @card.update! card_params

  respond_to do |format|
    format.turbo_stream
    format.json { render :show }
  end
end
# HTML with validation errors
def update
  if @user.update(user_params)
    respond_to do |format|
      format.html { redirect_to @user }
      format.json { head :no_content }
    end
  else
    respond_to do |format|
      format.html { render :edit, status: :unprocessable_entity }
      format.json { render json: @user.errors, status: :unprocessable_entity }
    end
  end
end

Pattern 8: Thin Controllers with Rich Models

Controllers handle HTTP concerns only (params, responses, redirects). Business logic lives in model methods with intention-revealing names; plain ActiveRecord operations are fine too — no service layer in between.

class Boards::PublicationsController < ApplicationController
  include BoardScoped

  before_action :ensure_permission_to_admin_board

  def create
    @board.publish    # business logic lives in Board
  end

  def destroy
    @board.unpublish
  end
end

# Plain ActiveRecord is fine
def create
  @comment = @card.comments.create!(comment_params)
end

Pattern 9: URL as State for GET Actions

Filters, tabs, search terms, and sort orders stored in session or JavaScript state make views impossible to link, bookmark, or share, and a refresh loses them. For GET actions, keep UI state in readable URL query params — shareable, bookmarkable, refresh-safe, back-button-friendly.

# /opportunities?category=acting&company=eutc&sort=newest
def index
  @opportunities = Opportunity.listable
    .then { |scope| params[:category].present? ? scope.where(category: params[:category]) : scope }
    .then { |scope| params[:company].present? ? scope.joins(:company).where(companies: { slug: params[:company] }) : scope }
end
<%# Tabs and filters are plain links that change params — no JS state %>
<%= link_to "Acting", opportunities_path(category: :acting) %>

Key Points:

  • Prefer human-readable values (?category=acting, slugs) over opaque ids where possible.
  • Forms that filter should use method: :get so submissions land in the URL.
  • Session/cookies are for cross-request identity, not view state.

Pattern 10: One-Line Route Docstrings on Actions

Give each public action a single comment naming its HTTP verb and path. It makes the controller scannable as a route map and catches actions that have drifted from their route (or shouldn't exist).

class PostsController < ApplicationController
  # GET /posts
  def index; end

  # GET /posts/:id
  def show; end

  # POST /posts
  def create; end

  # DELETE /posts/:id
  def destroy; end
end

Keep it to one line per action; the docstring describes the route, not the implementation. bin/rails routes -c posts is the source of truth if a comment and the routes disagree.


Pattern 11: Treat params as Read-Only — Never Mutate It

params is an ActionController::Parameters, and because it quacks like a Hash it's tempting to treat it as a scratchpad: normalise a value, delete a key a third-party API chokes on, merge in a default. Don't. params is shared request state. Every later line in the request — and any other code the params flow into (update, create!, a job, a serializer) — reads from the same object. Mutate it in one spot and you've silently changed the input for everything downstream. It's a debugging nightmare: the data is present at the top of the action and gone by the bottom, with nothing in the diff of the failing code to explain where it went.

# Bad — writing back into the request's params
def create
  params[:title] = params[:title].strip        # normalise in place
  params.delete(:honeypot)                      # drop a field before handing off
  params.merge!(account: Current.account)       # inject a value
  Thing.create!(thing_params)
end

# Good — read from params freely; build a SEPARATE hash and shape that
def create
  attrs = thing_params.merge(account: Current.account)  # non-bang merge → a new copy
  attrs[:title] = attrs[:title].to_s.strip              # []= on OUR copy is fine — attrs isn't params
  Thing.create!(attrs)
end

Two things make the "good" version fine, and they're the crux of the rule:

  • The rule is "don't mutate params", not "never merge or assign". merge and []= are perfectly fine the moment the receiver is an object you own (attrs) rather than the shared params.
  • Bang vs non-bang matters even on a copy. merge/except return a new object; only merge!/except!/delete mutate the receiver in place. And :honeypot needs no handling at all — thing_params simply never permitted it (see Pattern 4), so it's already gone; reaching into params to delete it was solving a problem strong params already solved.

The aliasing trap. Assigning params to another variable does not copy it — both names point at the same object, so mutating the "copy" mutates params:

mine = params
mine.delete(:q)   # params[:q] is now gone too

.dup/.clone only shallow-copy, so nested hashes are still shared. If you genuinely need a mutable structure, build your own: params.permit(...).to_h gives a plain, detached Hash you can do anything to.

Where the need really comes from. Almost every in-place mutation is a workaround for something that belongs elsewhere:

TemptationDo this instead
Set a default valueDefault in the DB column, the model (attribute :x, default:), or before_validation
Normalise user inputModel normalizes :attr, with: (Rails 7.1+), or derive a local before use
Strip a key a 3rd-party API rejectsBuild the API payload as its own hash from permitted params — don't reach back into params

The mechanical version of this rule is enforceable with a small custom RuboCop cop — see [[rails-style]] §10.


Quick Reference

PatternWhen to Use
Thin ApplicationControllerAlways - compose with concerns
Resource Scoping ConcernsWhen multiple controllers share parent resource
Nested Singular ResourcesNon-CRUD state changes (close, watch, pin)
Scoped Resource LoadingAlways - load through user's accessible scope
Permission before_actionsRestricting actions to authorized users
ETags with fresh_whenCacheable GET requests → [[rails-performance]]
respond_to blocksSupporting multiple response formats
Thin ControllersAlways - delegate logic to models
URL as StateGET actions with filters, tabs, search, or sort
Verb→noun nested resourceNaming a non-CRUD action (archive→ArchivesController#create)
# GET /posts/:id docstringsEvery public action — scannable route map
Never mutate paramsAlways — derive a new hash; params is shared request state

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.