Read more

A simpler default controller implementation

Henning Koch
October 16, 2013Software engineer at makandra GmbH

Rails has always included a scaffold script that generates a default controller implementation for you. Unfortunately that generated controller is unnecessarily verbose.

Illustration UI/UX Design

UI/UX Design by makandra brand

We make sure that your target audience has the best possible experience with your digital product. You get:

  • Design tailored to your audience
  • Proven processes customized to your needs
  • An expert team of experienced designers
Read more Show archive.org snapshot

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 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 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 an ActiveRecord::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 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 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
Henning Koch
October 16, 2013Software engineer at makandra GmbH
Posted by Henning Koch to makandra dev (2013-10-16 15:29)