Method Object Refactoring for Rails Apps and Narrow Controllers

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:

  1. Limited file size makes it easy to see the private methods behind a public method.
  2. Instance variables have a narrowly defined purpose.
  3. 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:

  1. “technical” refactoring to create a class that conforms to the Sandi rules.
  2. 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 the MicropostCreateController 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