Updated: A simpler default controller implementation

Posted . Visible to the public. Auto-destruct in 60 days

Added:

  • etag { flash.to_h }
  • etag { I18n.locale } (could be left out if all URLs contain a locale fragment, but also doesn't hurt and is a good default)

Changes

  • 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](https://github.com/makandra/active_type) form model.
  • For this example we will be CRUDing the following model:
  • ```ruby
  • class Note < ApplicationRecord
  • belongs_to :user
  • belongs_to :project
  • end
  • ```
  • A typical controller for this `Note` model would look like this:
  • ```ruby
  • 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](http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html) 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 a `Note` 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 the `Note` model directly.
  • By having `#note_scope` guard access to the `Note` 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](https://api.rubyonrails.org/classes/ActiveRecord/Scoping/Named/ClassMethods.html#method-i-scope) or [Consul](https://github.com/makandra/consul).
  • - The `#index` action uses the method `#load_notes` to load a list of notes. `#load_notes` activates [strict_loading](https://www.bigbinary.com/blog/rails-6-1-adds-strict_loading-to-warn-lazy-loading-associations) on the scope.
  • This forces to developer address [n+1 queries](https://dev.to/junko911/rails-n-1-queries-and-eager-loading-10eh) by preloading all associations used in the index. Using an association that is not preloaded will raise an `ActiveRecord::StrictLoadingViolationError`.
  • - There is a private method `#note_params` that returns a [permitted](https://api.rubyonrails.org/classes/ActionController/Parameters.html#method-i-permit) 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 code `422 Unprocessable Entity` instead of the default `200 OK`. This is (1) to be a good HTTP citizen and (2) to allow frontend code like [detect a failed form submission](https://unpoly.com/server-errors).
  • # 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`](https://www.rdoc.info/docs/rails/ActionController%2FConditionalGet:fresh_when)
  • 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.
  • ```ruby
  • 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`:
  • ```ruby
  • class ApplicationController < ActionController::Base
  • etag { session.id } # Change all ETags after resetting the Rack/Rails session
  • + etag { flash.to_h } # Produce different ETags when flash messages are set
  • 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
  • + etag { I18n.locale } # Ensure different locales lead to different ETags
  • 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
  • ```
Arne Hartherz
License
Source code in this card is licensed under the MIT License.
Posted by Arne Hartherz to makandra dev (2025-08-22 09:28)