Skip to content

Instantly share code, notes, and snippets.

@janko
Created May 27, 2015 22:38
Show Gist options
  • Save janko/edc007f65396d11d5599 to your computer and use it in GitHub Desktop.
Save janko/edc007f65396d11d5599 to your computer and use it in GitHub Desktop.
Controller refactoring (my presentation at a local Ruby meetup)
#################
# CLASSIC STYLE #
#################
class SessionsController < ApplicationController
# ...
def create
if user = AuthenticateUser.call(params[:username], params[:password])
sign_in!(user)
redirect_to root_path
else
flash[:error] = "Invalid username or password"
render :new
end
end
# ...
end
class PostsController < ApplicationController
# ...
def create
@post = CreatePost.call(params[:post])
if @post.valid?
redirect_to @post
else
render :new
end
end
def update
@post = UpdatePost.call(params[:id], params[:post])
if @post.valid?
redirect_to @post
else
render :edit
end
end
# ...
end
# Downsides:
# - Ugly `if` conditionals in all CRUD actions
# - Not very DRY
##############
# RESPONDERS #
##############
class SessionsController < ApplicationController
# ...
def create
if user = AuthenticateUser.call(params[:username], params[:password])
sign_in!(user)
redirect_to root_path
else
flash[:error] = "Invalid username or password"
render :new
end
end
# ...
end
class PostsController < ApplicationController
# ...
def create
respond_with CreatePost.call(params[:post])
end
def update
respond_with UpdatePost.call(params[:id], params[:post])
end
# ...
end
# Upsides:
# - No `if` conditionals 🎉
# - We're pretty DRY now
#
# Downsides:
# - We cannot use it for e.g. authentication, because those validatons don't happen on the model
# - We still have to repeat `respond_with` (but not a big deal)
#
# This is the best we can do for classic full-stack controller
############
# JSON API #
############
class SessionsController < ApplicationController
# ...
def create
if user = AuthenticateUser.call(params[:username], params[:password])
render json: user
else
render json: {errors: ["Invalid username or password"]}, status: 400
end
end
# ...
end
class PostsController < ApplicationController
# ...
def create
post = CreatePost.call(params[:post])
if post.valid?
render json: post
else
render json: {errors: post.errors}, status: 400
end
end
def update
post = UpdatePost.call(params[:id], params[:post])
if post.valid?
render json: post
else
render json: {errors: post.errors}, status: 400
end
end
# ...
end
# Again not DRY, but it's visible how we can DRY it up

Q: What are service objects? A: They are simple Ruby classes.

Q: Why do validation errors appear? A: Because the record was invalid.

Q: Why was the record invalid? A: Because the params were invalid.

Q: Who accepts the params A: Service objects.

Q: What do Ruby classes do when you give them invalid arguments? A: They raise an ArgumentError.

Validation errors are exceptional by nature, so it makes sense to raise errors

##########
# ERRORS #
##########
class SessionsController < ApplicationController
# ...
def create
render json: AuthenticateUser.call(params[:username], params[:password])
end
# ...
end
class PostsController < ApplicationController
# ...
def create
render json: CreatePost.call(params[:post])
end
def update
render json: UpdatePost.call(params[:id], params[:post])
end
# ...
end
class ApplicationController < ActionController::Base
# MyApp::Error::Validation < MyApp::Error
# MyApp::Error::Unauthorized < MyApp::Error
rescue_from MyApp::Error do |error|
render json: {errors: error.messages}, status: error.status
end
end
# Service classes raise validation or unauthorized errors.
# This really looks neat now. There is still the `render :json` duplication,
# but that's the best we can do in Rails.
#
# However, we can do even better if we switch frameworks.
########
# Roda #
########
class MyApp < Roda
plugin :json, serializer: Serializer, classes: Serializer::CLASSES
plugin :error_handler
route do |r|
# ...
r.get "/login" do
AuthenticateUser.call(params[:username], params[:password])
end
# ...
r.post "/posts" do
CreatePost.call(params[:post])
end
r.patch "/posts/:id" do |id|
UpdatePost.call(id, params[:post])
end
# ...
end
error do |error|
if MyApp::Error === error
response.status = error.status
error
else
raise error
end
end
end
class Serializer
CLASSES = [Hash, Array, ActiveRecord::Base, ActiveRecord::Relation, MyApp::Error]
def self.call(object)
case object
when Hash, Array
object.to_json
else
yaks.call(object)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment