I’m experimenting with creating a narrow controller rather than a “service object” and I’d like to get some opinions on these patterns. A more fundamental question I have is on the usefulness of creating very small methods and classes per the “Sandi Rules” if that means breaking classes up more on a “technical” level as opposed to a “domain” level. The things I like about very small methods and classes is that:
- Limited file size makes it easy to see the private methods behind a public method.
- Instance variables have a narrowly defined purpose.
- Since tests correspond typically to a class, as small class will have a proportionally small test file, which is also easier to grok.
How do we reconcile the difference between:
- “technical” refactoring to create a class that conforms to the Sandi rules.
- Refactoring based on the problem domain.
Could we possibly use a module grouping to capture the domain relationship between two method objects?
Here’s my two tries at refactoring an example of a complicated controller method. What do you like or not like about each?
Single Purpose Controller
This is a preferable alternative to pull request Earlier Draft: Service objects by justin808 · Pull Request #6 · shakacode/fat-code-refactoring-techniques · GitHub, which uses a Service Object pattern.
Objectives:
-
Splitting the
MicropostController
into theMicropostCreateController
to so
that a complicated controller method can be refactored to have many private
methods that relate to the only public method, on the “single purpose
controller”. I.e. conform to the Sandi rules of small methods. -
Demonstrate changing the routing for a custom named controller.
-
Demonstrate a controller spec that covers everything the service object spec
covered, but covers it more accurately because it actually verifies that the
controller is doing the right thing. -
Demonstrate following features of controller spec testing in rspec:
- testing flash.now
- checking instance variable
- checking rendered template
-
Demonstrate using a model object,
ProfanityChecker
to handle logic for a
potentially complex validation. -
Demonstrates simple tests of this ProfanityChecker class.
Here is the code example:
class MicropostCreateController < ApplicationController
before_action :signed_in_user
# By having one public method, all private methods relate to this one method
# and any instance variables can apply to all private methods
def create
@micropost = Micropost.new(micropost_params.merge(user: current_user))
return if current_user.minor? && used_profanity
save_micropost
end
private
def save_micropost
if @micropost.save
flash[:success] = "Micropost created!"
redirect_to root_url
else
error_rendering
end
end
# return true if checked to ensure no profanity violations
def used_profanity
if profanity_words.present?
profanity_found_side_effects
profanity_found_controller_response
end
end
def profanity_words
@profanity_words ||= ProfanityChecker.new(@micropost.content).profanity_words_contained
end
def profanity_found_controller_response
flash.now[:error] = <<-MSG
Whoa, better watch your language! Profanity: '#{profanity_words.join(", ")}' not allowed!
You've tried to use profanity #{view_context.pluralize(current_user.profanity_count, "time")}!
MSG
error_rendering
true
end
def error_rendering
@feed_items = []
render 'static_pages/home'
end
def profanity_found_side_effects
current_user.increment(:profanity_count, profanity_words.size)
current_user.save(validate: false)
send_parent_notifcation_of_profanity
end
def send_parent_notifcation_of_profanity
# pretend we sent an email, using @profanity_words in the content
end
def micropost_params
params.require(:micropost).permit(:content)
end
end
And here is the corresponding test:
require 'spec_helper'
describe MicropostCreateController do
let(:minor) { FactoryGirl.create :user, minor: true, profanity_count: 0 }
let(:adult) { FactoryGirl.create :user, minor: false, profanity_count: 0 }
let(:profanity_content) { "Dad is a poopface and fartface!" }
let(:clean_content) { "Dad is the best!" }
def flash_now
flash.instance_variable_get(:@now)
end
context "does contain two word profanity" do
subject(:service) { MicropostCreationService.new(minor, profanity_content) }
context "a minor" do
before { sign_in minor, no_capybara: true }
it "gives a flash with poopface and fartface" do
post :create, micropost: { content: profanity_content }
# Demonstrate testing flash.now
expect(flash_now[:error]).to match /poopface/
expect(flash_now[:error]).to match /fartface/
expect(flash_now[:error]).to match /2 times/
# Demonstrate checking rendered template
expect(response).to render_template('static_pages/home')
# Demonstrate checking instance variable
expect(assigns(:feed_items)).to eq([])
expect(response.status).to eq(200)
end
it "increases the minor's profanity count by 2" do
expect do
post :create, micropost: { content: profanity_content }
end.to change { minor.reload.profanity_count }.by(2)
end
it "does not increase the number of posts" do
expect do
post :create, micropost: { content: profanity_content }
end.not_to change { Micropost.count }
end
end
context "adult" do
before { sign_in adult, no_capybara: true }
it "gives a flash with poopface and fartface" do
post :create, micropost: { content: profanity_content }
expect(flash[:success]).to eq("Micropost created!")
expect(response).to redirect_to(root_url)
end
it "does not change the profanity count" do
expect do
post :create, micropost: { content: profanity_content }
end.not_to change { adult.reload.profanity_count }
end
it "does increase the number of posts" do
expect do
post :create, micropost: { content: profanity_content }
end.to change { Micropost.count }.by(1)
end
end
end
context "no profanity" do
context "minor" do
before { sign_in minor, no_capybara: true }
it "gives a flash with poopface and fartface" do
post :create, micropost: { content: clean_content }
expect(flash[:success]).to eq("Micropost created!")
expect(response).to redirect_to(root_url)
end
it "does not change the profanity count" do
expect do
post :create, micropost: { content: clean_content }
end.not_to change { minor.reload.profanity_count }
end
it "does increase the number of posts" do
expect do
post :create, micropost: { content: clean_content }
end.to change { Micropost.count }.by(1)
end
end
end
end