Anyone who has done any Rails development knows that views gets complicated very fast. Lately we've been experimenting with Carriers ( also called view services ) to clean up views.
There are many already solutions already existing to this problem like Draper Gem , or Cells from Trailblazer architecture.
We wanted to start with simplest solution by making use of simple ruby objects that takes care of business logic away from views.
A complex view
Consider this user model
1class User 2 def super_admin? 3 self.role == 'super_admin' 4 end 5 6 def manager? 7 self.role == 'manager' 8 end 9end
Here we have a view that displays appropriate profile link and changes css class based on the role of the user.
1 2<% if @user.super_admin? %> 3 <%= link_to 'All Profiles', profiles_path %> 4<% elsif @user.manager? %> 5 <%= link_to 'Manager Profile', manager_profile_path %> 6<% end %> 7 8<h3 class="<%= if @user.manager? 9 'hidden' 10 elsif @user.super_admin? 11 'active' 12 end %>"> 13 14 Hello, <%= @user.name %> 15</h3>
Extracting logic to Rails helper
In the above case we can extract the logic from the view to a helper.
After the extraction the code might look like this
1# app/helpers/users_helper.rb 2 3module UsersHelper 4 5 def class_for_user user 6 if @user.manager? 7 'hidden' 8 elsif @user.super_admin? 9 'active' 10 end 11 end 12 13end
Now the view is much simpler.
1<h3 class="<%= class_for_user(@user) %>"> 2 Hello, <%= @user.name %> 3</h3>
Why not to use Rails helpers?
Above solution worked. However in a large Rails application it will start creating problems.
UsersHelper is a module and it is mixed into ApplicationHelper. So if the Rails project has large number of helpers then all of them are mixed into the ApplicationHelper and sometimes there is a name collision. For example let's say that there is another helper called ShowingHelper and this helper also has method class_for_user. Now ApplicationHelper is mixing in both modules UsersHelper and ShowingHelper. One of those methods will be overridden and we would not even know about it.
Another issue is that all the helpers are modules not classes. Because they are not classes it becomes difficult to refactor helpers later. If a module has 5 methods and if we refactor two of the methods into two separate methods then we end up with seven methods. Now out of those seven methods in the helper only five of them should be public and the rest two should be private. However since all the helpers are modules it is very hard to see which of them are public and which of them are private.
And lastly writing tests for helpers is possible but testing a module directly feel weird since most of the time we test a class.
Carriers
Lets take a look at how we can extract the view logic using carriers.
1class UserCarrier 2 attr_reader :user 3 4 def initialize user 5 @user = user 6 end 7 8 def user_message_style_class 9 if user.manager? 10 'hidden' 11 elsif user.super_admin? 12 'active' 13 end 14 end 15end
In our controller
1 class UserController < ApplicationController 2 def show 3 @user = User.find(params[:id]) 4 @user_carrier = UserCarrier.new @user 5 end 6 end
Now the view looks like this
1 2<% if @user.super_admin? %> 3 <%= link_to 'All Profiles', profiles_path %> 4<% elsif @user.manager?%> 5 <%= link_to 'Manager Profile', manager_profile_path %> 6<% end %> 7 8<h3 class="<%= @user_carrier.user_message_style_class %>"> 9 Hello, <%= @user.name %> 10</h3>
No html markup in the carriers
Even though carriers are used for presentation we stay away from having any html markup in our carriers. That is because once we open the door to having html markups in our carriers then carriers quickly get complicated and it becomes harder to test them.
No link_to in the carriers
Since carriers are plain ruby objects, there is no link_to and other helper methods usually. And we keep carriers that way. We do not do include ActionView::Helpers::UrlHelper because the job of the carrier is to present the data that can be used in link_to and complement the usage of link_to.
We believe that link_to belongs to the ERB file. However if we really need to have an abstraction over it then we can create a regular Rails helper method. We minimize usage of Rails helper, we do not avoid it altogether.
Overcoming Double Dots
Many times in our view we end up doing
1 Email Preference for Tuesday: <%= @user.email_preferences.tuesday_preference %>
This is a violation of Law of Demeter . We call it "don't use Double Dots". Meaning don't do @article.publisher.full_name.
Its just a matter of time before views code looks like this
1 <%= @article.publisher.active.not_overdue.try(:full_name) %>
Since carriers encapsulate objects into classes, we can overcome this "double dots" issue by delegating behavior to appropriate object.
1class UserCarrier 2 attr_reader :user :email_preferences 3 4 delegate :tuesday_preference, to: :email_preferences 5 6 def initialize user 7 @user = user 8 @email_preferences = user.email_preferences 9 end 10 11end
After that refactoring we end up with cleaner views like.
1 Email Preference for Tuesday: <%= @user_carrier.tuesday_preference %>
Note that "Double dot" is allowed at other parts of the code. We do not allow it in views.
Testing
Since the carriers are simple ruby objects it's easy to test them.
1require 'test_helper' 2 3class UserCarrierTest < ActiveSupport::TestCase 4 fixture :users 5 6 def setup 7 manager = users(:manager) 8 @user_carrier = UserCarrier.new manager 9 end 10 11 def test_css_class_returned_for_manager 12 assert_equal 'hidden', @user_carrier.user_message_style_class 13 end 14 15end
Summary
Carriers allow us to encapsulate complex business logic in simple ruby objects.
This helps us achieve clearer separation of concern, clean up our views and avoid skewed and complex views. Our views are free of "double dots" and we end up with simple tests which are easy to maintain.
We decided to call it a "carrier" and not "presenter" because the word "presenter" is overloaded and has many meanings.
We at BigBinary take a similar approach for extracting code from a fat controller or a fat model. You can find out more about it here.