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
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 =).
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.