Rails has always included a scaffold
script that generates a default controller implementation for you. Unfortunately that generated controller is unnecessarily verbose.
When we take over Rails projects from other teams, we often find that controllers are the unloved child, where annoying glue code has been paved over and over again, negotiating between request and model using implicit and convoluted protocols.
We prefer a different approach. We believe that among all the classes in a Rails project, controllers are some of the hardest to test and understand and should receive special care. It is our belief that:
- Controllers should be short and tidy
- Controllers should receive the same amount of programming discipline as any other class
- Controllers should provide the minimum amount of glue to hand over a request to a model
- Unless there are good reasons against it, controllers should be built against a standard, proven implementation blueprint 99% of the time
Standard implementation (no caching)
Below you can find our standard implementation for a controller that CRUDs an ActiveRecord model or ActiveType Show archive.org snapshot form model.
For this example we will be CRUDing the following model:
class Note < ApplicationRecord
belongs_to :user
belongs_to :project
end
A typical controller for this Note
model would look like this:
class NotesController < ApplicationController
def show
load_note
end
def index
load_notes
end
def new
build_note
end
def create
build_note
if @note.save
redirect_to @note
else
render 'new', status: :unprocessable_entity
end
end
def edit
load_note
build_note
end
def update
load_note
build_note
if @note.save
redirect_to @note
else
render 'edit', status: :unprocessable_entity
end
end
def destroy
load_note
@note.destroy!
redirect_to :notes
end
private
def load_note
@note ||= note_scope.find(params[:id])
end
def build_note
@note ||= note_scope.build
@note.attributes = note_params
end
def note_params
params.require(:note).permit(
:title,
:text,
:published,
)
rescue ActionController::ParameterMissing
{}
end
def note_scope
# Restrict what the user may access by returning a scope with conditions.
Note.all
end
def load_notes
@notes ||= note_scope
.strict_loading # Raise an error when accessing an association that is not preloaded.
.preload(:user, :project) # Preload associations shown on the index.
.paginate(page: params[:page]) # Indexes should always be paginated.
.to_a # Convert to array to indicate the end of scope chaining.
end
end
Explanation
Note a couple of things:
-
Every controller action reads or changes a single model. Even if an update involves multiple models,
the job of finding and changing the involved records should be pushed to an orchestrating model.
You can do this with nested forms Show archive.org snapshot or skip to the chapter where we talk about form models. -
The controller actions are delegating most of their work to shared helper methods like
#load_note
or#build_note
. This allows us to not repeat ourselves
and is a great way to adapt the behavior of multiple controller actions by changing a single helper methods (e. g. if you want to place some restriction on
how objects are created, you probably want to apply the same restriction on how objects are updated). It also facilitates the implementation of
custom controller actions (e. g.NotesController#search
, not visible in the example). -
There is a private method
#note_scope
which is used by all member actions (#show
,#edit
,#update
and#destroy
) to load aNote
with the given ID.
It is also used by#index
to load the list of all notes. Note how at no point does an action talk to theNote
model directly.
By having#note_scope
guard access to theNote
model, we have a central place to control which records this controller can show, list or change.
This is a great technique to implement authorization schemes with named scopes Show archive.org snapshot or Consul Show archive.org snapshot . -
The
#index
action uses the method#load_notes
to load a list of notes.#load_notes
activates strict_loading Show archive.org snapshot on the scope.
This forces to developer address n+1 queries Show archive.org snapshot by preloading all associations used in the index. Using an association that is not preloaded will raise anActiveRecord::StrictLoadingViolationError
. -
There is a private method
#note_params
that returns a permitted Show archive.org snapshot attributes hash that can be set through the#update
and#create
actions. -
When validation errors prevented us from saving a record in
#update
and#create
, we respond with HTTP code422 Unprocessable Entity
instead of the default200 OK
. This is (1) to be a good HTTP citizen and (2) to allow frontend code like detect a failed form submission Show archive.org snapshot .
Standard implementation (with caching)
We can improve the performance of our controllers by having reading actions (#show
, #index
) set ETag
and Last-Modified
response headers using
#fresh_when
Show archive.org snapshot
This prevents unnecessary rendering when the browser cache already has a fresh copy of the content we're about to render. E.g. when we re-visit a site that we visited earlier.
When Rails detects a fresh cache it will not render the view template. Instead it will respond with 304 Not Modified
and an empty body.
class NotesController < ApplicationController
def show
load_note
# Build an ETag from all records shown in the view: @note, @note.user and @note.project
fresh_when expand_etag(@note, :user, :project)
end
def index
load_notes
fresh_when expand_etag(@notes, :user, :project)
end
def new
build_note
fresh_when @note
end
def create
build_note
if @note.save
redirect_to @note
else
render 'new', status: :unprocessable_entity
end
end
def edit
load_note
build_note
fresh_when @note
end
def update
load_note
build_note
if @note.save
redirect_to @note
else
render 'edit', status: :unprocessable_entity
end
end
def destroy
load_note
@note.destroy!
redirect_to :notes
end
private
def load_note
@note ||= note_scope.find(params[:id])
end
def build_note
@note ||= note_scope.build
@note.attributes = note_params
end
def note_params
params.require(:note).permit(
:title,
:text,
:published,
)
rescue ActionController::ParameterMissing
{}
end
def note_scope
# Restrict what the user may access by returning a scope with conditions.
Note.all
end
def load_notes
@notes ||= note_scope
.strict_loading # Raise an error when accessing an association that is not preloaded.
.preload(:user, :project) # Preload associations shown on the index.
.paginate(page: params[:page]) # Indexes should always be paginated.
.to_a # Convert to array to indicate the end of scope chaining.
end
end
This also requires some changes in your ApplicationController
:
class ApplicationController < ActionController::Base
etag { session.id } # Change all ETags after resetting the Rack/Rails session
etag { current_user } # Change all ETags after logging in or out
etag { @@revision ||= File.read('REVISION') rescue nil } # Change all ETags after a new Capistrano release.
etag { up.target } # Add this when you use Unpoly < 3 and optimize responses for render targets
private
# Builds an ETag input from the given records and a list of association names.
def expand_etag(record_or_records, *associations)
records = Array.wrap(record_or_records)
records.map { |record|
[record, associations.map { |association|
[association, record.public_send(association)]
}]
}.flatten
end
end