CRUD Patterns (37signals)
Map any action to CRUD. When something doesn't fit standard CRUD, create a new resource.
Project knowledge
Tech Stack: Rails 8.2 (edge), Turbo, Stimulus, Solid Queue, MySQL/SQLite
Routing: Use scope module: for namespacing nested resources
Controllers: Thin with concerns for shared behavior
Commands:
bin/rails routes | grep cards # Check routes
bin/rails generate controller cards/closures # Generate controller
bin/rails test test/controllers/ # Run controller tests
Resource thinking
When asked to add functionality, ask: "What resource does this represent?"
| User request | Resource to create |
|---|---|
| "Let users close cards" | Cards::ClosuresController (create/destroy) |
| "Let users mark important" | Cards::GoldnessesController (create/destroy) |
| "Let users follow a card" | Cards::WatchesController (create/destroy) |
| "Let users assign cards" | Cards::AssignmentsController (create/destroy) |
| "Let users publish boards" | Boards::PublicationsController (create/destroy) |
| "Let users position cards" | Cards::PositionsController (update) |
| "Let users archive projects" | Projects::ArchivalsController (create/destroy) |
State change controllers (singular resources)
Toggle state via POST (create) and DELETE (destroy) on a singular resource:
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
include CardScoped # Provides @card, @board
def create
@card.close
render_card_replacement
end
def destroy
@card.reopen
render_card_replacement
end
end
Standard CRUD controllers (plural resources)
# app/controllers/cards/comments_controller.rb
class Cards::CommentsController < ApplicationController
include CardScoped
def index
@comments = @card.comments.recent
end
def create
@comment = @card.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @card }
end
end
private
def comment_params
params.require(:comment).permit(:body)
end
end
Nested resource controllers
# app/controllers/boards/columns_controller.rb
class Boards::ColumnsController < ApplicationController
include BoardScoped
def show
@column = @board.columns.find(params[:id])
@cards = @column.cards.positioned
end
def create
@column = @board.columns.create!(column_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @board }
end
end
def update
@column = @board.columns.find(params[:id])
@column.update!(column_params)
head :no_content
end
def destroy
@column = @board.columns.find(params[:id])
@column.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to @board }
end
end
private
def column_params
params.require(:column).permit(:name, :position)
end
end
Routing patterns
Singular resource for toggles
resource :closure, only: [:create, :destroy] # No :show, :edit, :new
Module scoping for organization
resources :cards do
scope module: :cards do
resources :comments
resources :attachments
resource :closure
resource :goldness
end
end
Polymorphic routes with resolve
resolve "Comment" do |comment, options|
options[:anchor] = ActionView::RecordIdentifier.dom_id(comment)
route_for :card, comment.card, options
end
Scoping concerns
Provide parent resource lookup for nested controllers:
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card
before_action :set_board
end
private
def set_card
@card = Current.account.cards.find(params[:card_id])
end
def set_board
@board = @card.board
end
def render_card_replacement
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
dom_id(@card, :card_container),
partial: "cards/container",
locals: { card: @card.reload }
)
end
format.html { redirect_to @card }
end
end
end
Create new scoping concerns as needed:
# app/controllers/concerns/project_scoped.rb
module ProjectScoped
extend ActiveSupport::Concern
included do
before_action :set_project
end
private
def set_project
@project = Current.account.projects.find(params[:project_id])
end
end
Response patterns
Turbo Stream responses (preferred)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @resource }
end
Multi-format responses
def create
@resource = Model.create!(resource_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @resource }
format.json { render json: @resource, status: :created, location: @resource }
end
end
Authorization pattern
Keep authorization checks thin, delegate logic to models:
before_action :ensure_can_administer_card, only: [:destroy]
private
def ensure_can_administer_card
head :forbidden unless Current.user.can_administer_card?(@card)
end
Files to create for a new resource
- Controller:
app/controllers/[namespace]/[resource]_controller.rb - Route entry: Add to
config/routes.rb - Test:
test/controllers/[namespace]/[resource]_controller_test.rb - Concern (if needed):
app/controllers/concerns/[resource]_scoped.rb
Boundaries
- Always: Map actions to CRUD, create new resources for state changes, use concerns for scoping, only 7 REST actions, use strong parameters
- Ask first: Before adding custom actions, before creating non-REST routes, before modifying routing constraints
- Never: Add member/collection routes, create controllers without tests, put business logic in controllers