Benito Serna Tips and tools for Ruby on Rails developers

A controller that knows less

January 18, 2015

I want to show you a way you can “draw a line” between your application “main logic” and your Rails controllers.

In this example I will compare a new approach with the one that I took in my last post about thinking in small programs

Example

This example is from an application that provides “web sites” for companies. Something similar to tumblr but for companies.

In the application the “site” of the company is not public by default. And in order to be published the admin of the company must filled the “phrase” and “description” of the company.

Working with this information and thinking in designing small programs we can propose the next solution.

class Admin::CompaniesController < Admin::BaseController
  before_action :authorize_can_publish_site, only: :publish

  def publish
    if site.can_be_published?
      current_company.publish!
      redirect_to current_company, notice: 'Success'
    else
      redirect_to current_company, alert: 'Failure'
    end
  end

  private

  def site
    @site ||= Sites.site_for(current_company)
  end

  def authorize_can_publish_site
    unless site.admin?(current_user)
      redirect_to current_company, alert: 'Unauthorized'
    end
  end
end

But as you can see even though we are abstracting the knowledge about the site we are having a lot of “control” logic. This does not sound very bad because we are in a “controller” but I think the Rails controllers should be dealing just with the communication stuff merging all the pieces together. I saw them must as objects that knows “who” can make something rather than “how” something can be made.

So I want to propose another solution. I want to draw a line between the things that makes something and the thing that knows when those other things are “called”.

I will use what Ivar Jacobson calls “control object” in his book Object-Oriented software engineering (or at least what I understand it is).

The control objects model functionality that is not naturally tied to any other object, for example, behavior that consist of operating on several different entity objects, doing some computations and then returning the result to an interface object.

So this is my next approach:

class Admin::CompaniesController < Admin::BaseController
  def publish
    Sites.publish_company_site(
      company_record: current_company,
      user_record: current_admin,

      update_company_record: ->(attributes) do
        current_company.update_attributes(attributes)
      end,

      on_success: ->(message) do
        redirect_to current_company, notice: message
      end,

      on_failure: ->(message) do
        redirect_to current_company, alert: message
      end
    )
  end
end

I know I can declare those “callbacks” as methods in the controller and just pass the controller to the Sites.publish_company_site method, but I think this is far more explicit.

As you see know the controller still knows that an active record object can update its attributes, but doesn’t know when and which attributes to update. Also knows how (I think this “how” is ok =)) to redirect but doesn’t know when to do it or the message that is set.

For this method I will propose the next specification.

# spec/modules/sites/publish_company_site_spec.rb
require_relative '../sites_spec'

module Sites
  describe 'publish company site' do
    attr_reader :user, :admin, :update_company_record, :on_success, :on_failure

    before do
      @user = user_with(id: 'other-user-id')
      @admin = user_with(id: 'admin-id')
      @update_company_record = ->(attributes) {}
      @on_success = ->(message) {}
      @on_failure = ->(message) {}
    end

    it 'updates company record with has_published_site: true' do
      company = company_ready_to_publish_with(has_published_site: false, admin_id: 'admin-id')
      update_company_record.should_receive(:call).with(has_published_site: true)
      publish_site(company, admin)
    end

    it 'does not updates the company record when the site is not ready to publish' do
      company = company_with(has_published_site: false, admin_id: 'admin-id')
      update_company_record.should_not_receive(:call)
      publish_site(company, admin)
    end

    it 'does not updates the company record when the user is not the company admin' do
      company = company_ready_to_publish_with(has_published_site: false, admin_id: 'admin-id')
      update_company_record.should_not_receive(:call)
      publish_site(company, user)
    end

    it 'calls :on_success' do
      company = company_ready_to_publish_with(has_published_site: false, admin_id: 'admin-id')
      on_success.should_receive(:call).with("El sitio de tu empresa ha sido publicado exitosamente")
      publish_site(company, admin)
    end

    it 'calls :on_failure if the site is not ready to be published' do
      company = company_with(has_published_site: false, admin_id: 'admin-id')
      on_success.should_not_receive(:call)
      on_failure.should_receive(:call).with("El sitio de tu empresa aun no puede ser publicado")
      publish_site(company, admin)
    end

    it 'calls :on_failure if the site is already published' do
      company = company_ready_to_publish_with(has_published_site: false, admin_id: 'admin-id')
      on_success.should_not_receive(:call)
      on_failure.should_receive(:call).with("Tu no puedes hacer público este sitio")
      publish_site(company, user)
    end

    def user_with(attrs)
      TestUserRecord.new(attrs)
    end

    def company_with(attrs)
      TestCompanyRecord.new(attrs)
    end

    def company_ready_to_publish_with(attrs)
      TestCompanyRecord.new(attrs.merge(description: 'A description', phrase: 'A phrase'))
    end

    def publish_site(company, user)
      Sites.publish_company_site(
        company_record: company,
        user_record: user,
        update_company_record: update_company_record,
        on_success: on_success,
        on_failure: on_failure
      )
    end
  end
end

My implementation for the specification is…

module Sites
  def self.publish_company_site(attrs)
    PublishCompanySite.new(attrs.merge(site: Site.new(attrs))).call
  end

  class Site
    def initialize(attrs)
      @company = attrs.fetch(:company_record)
    end

    def ready_to_publish?
      company.phrase.present? && company.description.present?
    end

    def admin?(user)
      user && company.admin_id == user.id
    end

    private

    attr_reader :company
  end

  class PublishCompanySite
    def initialize(attrs)
      @site = attrs.fetch(:site)
      @user = attrs.fetch(:user_record)
      @update_company_record = attrs.fetch(:update_company_record)
      @on_success = attrs.fetch(:on_success)
      @on_failure = attrs.fetch(:on_failure)
    end

    def call
      unless site.admin?(user)
        return on_failure.call("Tu no puedes hacer público este sitio")
      end

      if site.ready_to_publish?
        update_company_record.call(has_published_site: true)
        on_success.call("El sitio de tu empresa ha sido publicado exitosamente")
      else
        on_failure.call("El sitio de tu empresa aun no puede ser publicado")
      end
    end

    private

    attr_reader :site, :user, :update_company_record, :on_success, :on_failure
  end
end

With this solution we are extracting a more of the application rules from the framework and also we are expressing them in “simple” ruby objects that I think every ruby programmer can understand.

And as we have test that run really fast, if you need to make a change on the behavior you will be able to do it with confidence =).

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.