TECH

Trailblazer tutorial: updating old fat controller - part 4.

blogpost

Since we prepared and tested Operation and Contract to create Proposal with Trailblazer way, it is time to clean the mess in the controller. Let's remind how our controller #create action looks like right now:

def create  
  if @event.closed? && @event.closes_at < 1.hour.ago  
    redirect_to event_path (@event)  
    flash[:danger] = "The CFP is closed for proposal submissions."  
  return  
 end  @proposal = @event.proposals.new(proposal_params)  
  speaker = @proposal.speakers[0]  
  speaker.user_id = current_user.id  
  speaker.event_id = @event.id  

  if @proposal.save  
    current_user.update_bio  
    flash[:info] = setup_flash_message  
    redirect_to event_proposal_url(event_slug: @event.slug, uuid: @proposal)  
  else  
  flash[:danger] = "There was a problem saving your proposal."  
  render :new  
  end  
end

Since we have all business logic moved to Operation, and all context-validation rules applied into the controller let's use it. But before, since we are refactoring our controller, let us improve test-coverage for all cases in this action, so we will be able to sleep well after deploying refactored code to production:

# spec/controllers/proposals_controller_spec.rb
require 'rails_helper'  

describe ProposalsController, type: :controller do  
  let(:event) { FactoryBot.create(:event, state: 'open') }
  # ...
  describe 'POST #create' do  
    let(:proposal) { build(:proposal, uuid: 'abc123') }  
    let(:user) { create(:user) }  
    let(:params) {  
      {  
        event_slug: event.slug,  
        proposal: {  
          title: proposal.title,  
          abstract: proposal.abstract,  
          details: proposal.details,  
          pitch: proposal.pitch,  
          session_format_id: proposal.session_format.id,  
          speakers: [  
            {  
              bio: 'my bio'  
            }  
          ]  
        }  
      }  
    }  

    before { allow(controller).to receive(:current_user).and_return(user) }  

    it "create Proposal" do  
      expect{  
        post :create, params: params  
      }.to change{Proposal.count}.by(1)  
    end

    it "sets the user's bio if not is present" do  
      user.bio = nil  
      post :create, params: params  
      expect(user.bio).to eq('my bio')  
    end  

    context "event closed" do  
      let!(:event) { FactoryBot.create(:event, state: "closed") }  
      it "redirects to event show page with 'The CFP is closed for proposal submissions.'  message" do  
        post :create, params: params  
        expect(response).to redirect_to(event_path(event))  
        expect(flash[:danger]).to match(/The CFP is closed for proposal submissions.*/)  
      end
    end  

    context "wrong slug passed" do  
      let(:wrong_params) { params.merge(event_slug: 'Non-existing-slug')}  
        it "re-render new form with 'Your event could not be found, please check the url.' message" do  
        post :create, params: wrong_params  
        expect(response).to redirect_to(events_path)  
        expect(flash[:danger]).to match(/Your event could not be found, please check the url.*/)  
      end  
    end
  end
end

Before we will start refactoring controller code, let's take a look for what as Rails developers we love so much:

class ProposalsController < ApplicationController  
  before_action :require_event, except: :index  
  before_action :require_user  
  before_action :require_proposal, except: [ :index, :create, :new, :parse_edit_field ]  
  before_action :require_invite_or_speaker, only: [:show]  
  before_action :require_speaker, except: [ :index, :create, :new, :parse_edit_field ]
  #a lot of actions

  private  
  def proposal_params  
    params.require(:proposal).permit(:title, {tags: []}, :session_format_id,
      :track_id, :abstract, :details, :pitch, custom_fields: @event.custom_fields,  
      comments_attributes: [:body, :proposal_id, :user_id],  
      speakers_attributes: [:bio, :id])  
  end
end

That is not a tutorial which goal is to convince you that having a lot of before_action callback will bite you sooner than later. I also don't want to try to list all OOP rules that this approach breaks. I just strongly believe that you understand why we will try to avoid using callbacks, and if you don't - then you can google it :) Meantime we will focus on our step-by-step tutorial.

So what we are planning to do with our controller is:

  • replace require_event as a callback into subprocess called from Operations that needs event (what we already have in our Operation),
  • change setup_flash_message to expect receive event argument, instead of using instance variable inside,
  • stop using strong params since contract using reform are taking care of it better than whole controller ( which doesn't know the context of each action, if it has one strong params method for the whole set of actions ),
  • use Proposal::Operation::Create and remove all business logic from controller,
  • ensure that our controller is only responsible for rendering proper response.

So how our new action would look like after refactor:

def create  
  result = Proposal::Operation::Create.call(params: params, current_user: current_user)  
  if result.success?  
   flash[:info] = setup_flash_message(result[:model].event)  
   redirect_to event_proposal_url(event_slug: result[:model].event.slug, uuid: result[:model])  
  elsif result[:errors] == "Event not found"  
    flash[:danger] = "Your event could not be found, please check the url."  
    redirect_to events_path  
  elsif result[:errors] == "Event is closed"  
    flash[:danger] = "The CFP is closed for proposal submissions."  
    redirect_to event_path(slug: result[:model].event.slug)  
  end
end

We also can exclude our action from before_action:

before_action :require_event, except: [:index, :create]

After we run all tests for #create context we get:

Run options: include {:locations=>{"./spec/controllers/proposals_controller_spec.rb"=>[35]}}
....

Finished in 1.37 seconds (files took 8.54 seconds to load)
4 examples, 0 failures

So that was it. Refactoring of #create action is finished. So let's slow down a bit, and think about what we have done, and what we achieved: we created 2 new abstract layers (Operation and Contract) that are more explicit and readable - they also have their own responsibilities. Both Operation and Contract are easier to test, they are isolated and easier to reuse.

Operation:
> It’s a simple service object that encapsulates and orchestrates all the business logic necessary to accomplish a certain task, such as creating a blog post or updating a user.

Contract:
> A contract is an abstraction to handle the validation of arbitrary data or object state. It is a fully self-contained object that is orchestrated by the operation.

We cleared controller so:
- it is not an example of how to break all SOLID rules at one time,
- it is easier to test - we can mock the whole Operation result instead of running integration test if we care about speed and isolation,
- we don't use before_action callback anymore so it is more explicit what is happening there and when,
- we get rid of strong params, so we can validate params that we receive on a contract layer in the context of a given domain process.

This was the first step in refactoring. Meantime we added few #ToDos, so now - we will focus on moving forward and clearing those parts. Let us know if you have any questions/feedback in comments or just join the Trailblazer community on gitter ( https://gitter.im/trailblazer/chat ).

If you still want to see some code, there is PR with our changes:
https://github.com/panSarin/cfp-app/pull/1/files.

Read more on our blog

Check out the knowledge base collected and distilled by experienced
professionals.
bloglist_item
Tech

Over the years I had to deal with applications and system that have a long history of already being "legacy".
On top of that I met with clients/product owners that never want you to spend time ref...

bloglist_item
Tech

How many times have you searched for that one specific library that meets your needs? How much time have you spent customizing it to fit your project's requirements? I must admit, waaay too much. T...