Files
claude-engineering-plugin/plugins/compound-engineering/skills/dhh-rails-style/references/controllers.md
Kieran Klaassen 4fb831ac85 feat(skills): Add dhh-rails-style skill for 37signals Rails conventions
Router-pattern skill with sectioned references:
- controllers.md: REST mapping, concerns, Turbo, API patterns
- models.md: Concerns, state records, callbacks, scopes
- frontend.md: Turbo, Stimulus, CSS architecture
- architecture.md: Routing, auth, jobs, caching, config
- gems.md: What they use vs avoid, decision framework

Based on analysis of Fizzy (Campfire) codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:30:17 -08:00

3.4 KiB

Controllers - DHH Rails Style

<rest_mapping>

Everything Maps to CRUD

Custom actions become new resources. Instead of verbs on existing resources, create noun resources:

# Instead of this:
POST /cards/:id/close
DELETE /cards/:id/close
POST /cards/:id/archive

# Do this:
POST /cards/:id/closure      # create closure
DELETE /cards/:id/closure    # destroy closure
POST /cards/:id/archival     # create archival

Real examples from 37signals:

resources :cards do
  resource :closure       # closing/reopening
  resource :goldness      # marking important
  resource :not_now       # postponing
  resources :assignments  # managing assignees
end

Each resource gets its own controller with standard CRUD actions. </rest_mapping>

<controller_concerns>

Concerns for Shared Behavior

Controllers use concerns extensively. Common patterns:

CardScoped - loads @card, @board, provides render_card_replacement

module CardScoped
  extend ActiveSupport::Concern

  included do
    before_action :set_card
  end

  private
    def set_card
      @card = Card.find(params[:card_id])
      @board = @card.board
    end

    def render_card_replacement
      render turbo_stream: turbo_stream.replace(@card)
    end
end

BoardScoped - loads @board CurrentRequest - populates Current with request data CurrentTimezone - wraps requests in user's timezone FilterScoped - handles complex filtering TurboFlash - flash messages via Turbo Stream ViewTransitions - disables on page refresh </controller_concerns>

<turbo_responses>

Turbo Stream Responses

Use Turbo Streams for partial updates:

class Cards::ClosuresController < ApplicationController
  include CardScoped

  def create
    @card.close
    render_card_replacement
  end

  def destroy
    @card.reopen
    render_card_replacement
  end
end

For complex updates, use morphing:

render turbo_stream: turbo_stream.morph(@card)

</turbo_responses>

<api_patterns>

API Design

Same controllers, different format. Convention for responses:

def create
  @card = Card.create!(card_params)

  respond_to do |format|
    format.html { redirect_to @card }
    format.json { head :created, location: @card }
  end
end

def update
  @card.update!(card_params)

  respond_to do |format|
    format.html { redirect_to @card }
    format.json { head :no_content }
  end
end

def destroy
  @card.destroy

  respond_to do |format|
    format.html { redirect_to cards_path }
    format.json { head :no_content }
  end
end

Status codes:

  • Create: 201 Created + Location header
  • Update: 204 No Content
  • Delete: 204 No Content
  • Bearer token authentication </api_patterns>

<http_caching>

HTTP Caching

Extensive use of ETags and conditional GETs:

class CardsController < ApplicationController
  def show
    @card = Card.find(params[:id])
    fresh_when etag: [@card, Current.user.timezone]
  end

  def index
    @cards = @board.cards.preloaded
    fresh_when etag: [@cards, @board.updated_at]
  end
end

Key insight: Times render server-side in user's timezone, so timezone must affect the ETag to prevent serving wrong times to other timezones.

ApplicationController global etag:

class ApplicationController < ActionController::Base
  etag { "v1" }  # Bump to invalidate all caches
end

Use touch: true on associations for cache invalidation. </http_caching>