Let's do a quick exercise where we take a typical Rails controller and streamline it.
I don't normally use Rails' scaffolding but let's use it to generate a typical CRUD controller for a new object in our project.
rails g scaffold comments title:string body:string --api
Cool. So let's pop open that controller and take a look at it. If you're an experienced Rails developer, this is going to look extremely familiar.
class CommentsController < ApplicationController
before_action :set_comment, only: %i[ show update destroy ]
def index
@comments = Comment.all
end
def show
end
def create
@comment = Comment.new(comment_params)
if @comment.save
render :show, status: :created, location: @comment
else
render json: @comment.errors, status: :unprocessable_entity
end
end
def update
if @comment.update(comment_params)
render :show, status: :ok, location: @comment
else
render json: @comment.errors, status: :unprocessable_entity
end
end
def destroy
@comment.destroy
end
private
def set_comment
@comment = Comment.find(params[:id])
end
def comment_params
params.require(:comment).permit(:title, :body)
end
end
I'm not here to knock the scaffolded code. It's designed to be extremely approachable for beginners and it succeeds at that.
A couple of areas I'd improve here:
set_comment
could have just been a memoized method, no need to make it a before_action
Let's take a quick beat to code golf this a bit before moving on.
class CommentsController < ApplicationController
def index
comments
end
def show
comment
end
def create
persist
end
def update
persist
end
def destroy
comment.destroy
end
private
def persist
if comment.update(comment_params)
status = comment.previously_new_record? ? :created : :ok
render :show, status: status, location: comment
else
render json: comment.errors, status: :unprocessable_entity
end
end
def comment
@comment ||= begin
if action_name == 'create'
comments.new
else
comments.find(params[:id])
end
end
end
def comments
@comments ||= Comment.all
end
def comment_params
params.require(:comment).permit(:title, :body)
end
end
Not perfect, but you get the idea. We're feeding the rest of the controller from the same comments
method, so there's a single point of responsibility where you can later scope your controller so that's great. Having slimmed things down a little, let's go a step deeper.
The next step is to pull the boilerplate out into a concern that handles all the typical model interactions for you in a generic fashion.
module Boilerplate
extend ActiveSupport::Concern
included do
def show
resource
end
def index
collection
end
%i[create update].each do |action|
define_method(action) { persist }
end
def destroy
resource.destroy
end
end
protected
def model_klass
@model_klass ||= controller_name.classify.constantize
end
def collection
@collection ||= model_klass.all
end
def resource
@resource ||= begin
if action_name == 'create'
collection.new
else
collection.find(params[:id])
end
end
end
def persist
if resource.update(resource_params)
status = resource.previously_new_record? ? :created : :ok
render :show, status: status, location: resource
else
render json: resource.errors, status: :unprocessable_entity
end
end
end
What follows is an almost bare controller. All of your controllers that are just there to do generic CRUD actions can be set up this way.
class CommentsController < ApplicationController
include Boilerplate
private
def resource_params
params.require(:comment).permit(:title, :body)
end
end
If you have a controller that almost behaves appropriately for this pattern but doesn't quite fit, just overwrite the methods that need special behavior.
One minor compromise I've made for simlicity is to swap the instance variables in the scaffolded views from @comment/@comments to @resource/@collection. I wouldn't recommend it, but if you REALLY don't want generic instance variable names for your views, add something like this to your boilerplate concern:
def resource_name
@resource_name ||= collection_name.singularize
end
def collection_name
@collection_name ||= "@#{model_klass.table_name}"
end
def collection
instance_variable_get(collection_name) || instance_variable_set(collection_name, model_klass.all)
end
def set_resource
if action_name == 'create'
collection.new
else
collection.find(params[:id])
end
end
def resource
instance_variable_get(resource_name) || instance_variable_set(resource_name, set_resource)
end