Sunday, October 02, 2011

Decoupling Views from Controllers in Rails (Smalltalk MVC Style)

Updated on 2022-09-23: mostly clarifications for the latest versions of Rails

Last week, I gave a talk at the Groupon weekly GeekFest event titled "Smalltalk MVC Applied in Rails". Though the talk briefly touched upon Smalltalk's original MVC pattern and how it is applied in desktop development, the main focus of the talk was on decoupling views from controllers in Rails the way they are decoupled in Smalltalk MVC, so I would like to elaborate more on that in this blog post.

The diagram above shows the relationship between Model, View, and Controller in Smalltalk MVC with desktop development.


  • The Controller observes the View for changes caused by user interactions (e.g. clicking a button, making a selectiong, etc...)
  • The Controller causes updates in the Model when it receives a notification from the View
  • The View observes the Model for changes caused by invokations from the Controller (e.g. add a new contact) or by Model change events (e.g. a customer birthday has been reached in system time)
  • The View refreshes itself automatically from the appropriate models it is providing a view of.
Notice that nowhere in that flow does the Controller directly interact with the View to update it with Model data. In fact, well written desktop applications avoid that sort of coupling to ensure clean separation between control flow and presentation logic.

One thing to note also about Smalltalk MVC on the desktop is that Views are objects in desktop applications with the intelligence of any other objects. Like Models, they are responsible for maintaining their own state as per the object oriented paradigm. They do not require another class like a Controller to update them. However, when presentation logic gets complex enough, it is a good practice to then split that into a Presenter layer between Views and Models that follows the Adapter design pattern, adapting View state (e.g. index of contact selected in a list of contacts) into Models (the actual contact object representing the index)

Now, if we were to transfer all of these ideas transparently to the web, View state is simply the parameters (request or session) that are populated by user actions. Controllers get access to them temporarily on user actions to cause updates in Models, but then once they render a View, the View itself can be responsible for translating its state (parameters) into Model objects via a Presenter layer. The Presenter layer in Rails is nothing but the good old (badly named) Rails Helpers. They automatically get access to the View context (request and session parameters), allowing them to act as Adapters that neatly hide the details of converting request and session parameters into Model objects the View can rely on to render its contents. This frees Controllers to focus only on Model updates and routing control logic, avoiding the typical clutter with Model loading logic that we often see in Rails applications. This then decouples the Views from Controllers the way they are in original Smalltalk MVC.

Here is an example of typical Rails MVC code:


Controller:

def show
  @contact = Contact.find(params[:id])
  @region = Region.find(session[:region_id])
  @friend_contacts = @contact.friends_by(@region)
  @news = News.latest
end

View:

_contact.html.erb:

Name: <%= @contact.first_name %>
Last Name: <%= @contact.last_name %>
Phone: <%= @contact.phone_number %>
...

_region_header.html.erb:

<%= @region.name %>
<%= @region.state %>
<%= @region.city %>
...

_friends.html.erb:

Friends
<% @friend_contacts.each do |friend_contact| %>
  <%= link_to friend_contact.name, contact_path(friend_contact) %>
<% end %>
...

_news_feed.html.erb:

Latest Happenings:
<% @news.each do |news_feed_item| %>
  <%= news_feed_item.story %>
<% end %>
...

Here is the same example benefiting from the Smalltalk MVC pattern:

Controller:

def show
end

Helper (Presenter):

def contact
  Contact.find(params[:id])
end

def region
  Region.find(session[:region_id])
end

def friend_contacts
  contact.friends_by(region)
end

View:

_contact.html.erb:

Name: <%= contact.first_name %>
Last Name: <%= contact.last_name %>
Phone: <%= contact.phone_number %>
...

_region_header.html.erb:

<%= region.name %>
<%= region.state %>
<%= region.city %>
...

_friends.html.erb:

Friends
<% friend_contacts.each do |friend_contact| %>
  <%= link_to friend_contact.name, contact_path(friend_contact) %>
<% end %>
...

_news_feed.html.erb:

Latest Happenings:
<% News.latest.each do |news_feed_item| %>
  <%= news_feed_item.story %>
<% end %>
...


Notice how the last partial did not even need a Presenter and went to the Model directly since it did not rely on any specific View state (parameters).

Of course, you might want to memoize helpers if you use repeatedly in the view:

Helper (Presenter) with memoization:

def contact
   @contact ||= Contact.find(params[:id])
end

def region
   @region ||= Region.find(session[:region_id])
end

def friend_contacts
   @friend_contacts ||= contact.friends_by(region)
end

Here is a summary of the benefits of decoupling Views from Controllers by allowing them to refresh their data directly from Models as per Smalltalk MVC or use Helpers as Presenters/Adapters for View state (request and session parameters):
  1. Unclutter Controllers from data loading logic for multiple objects that the View needs, allowing each part of the View to load its data directly.
  2. Make View partials easily reusable as they rely on Presenters/Adapters (Helpers) to load their data by pull instead of having to include code in every reusing Controller to push the data into the Views.
  3. Easily test-drive and maintain the logic of Presenting/Adapting View data in small cleanly separated methods instead of having that logic all mixed in Controllers.
  4. When Models needed for the View have dependencies in their load order, there is no need to explicitly order their loading in the Controller. Helper methods can be composed of other helper methods, resolving the dependencies automatically.
  5. Avoid the dissonance in View code caused by a mix of "@object" references and Helper "object" references. All objects in the View get populated from Helpers with "object" references or directly from model classes making the code more readable.
  6. Trivial extraction of partials from Views given that they do not contain any "@object" variables and all references are "object" references. Developers thus do not need to put any effort into error-prone switching of "@object" variables into "object" locals. The helper "object" references can already serve as locals.
  7. Controllers already have access to the context of Presenters (Helpers) thus are able to reuse the View data loading logic without the need to duplicate.
  8. If multiple Controller actions and Views rely on the same data object being present as in the new, create, edit, update, and show actions (e.g. contact object). A single Presenter method (e.g. "contact" helper) can take care of loading that object regardless of whether new or existing in the database already (e.g. Contact.find_or_initialize_by_id(params[:id]) [more recently Contact.find_or_initialize_by(id: params[:id])] )
These are some of the benefits experienced in my last three Rails projects, giving the team great flexibility in maintaining Views, Models, Controllers, and Presenters without the mix of concerns typically experienced in Controllers, allowing for much easier test-driven development and flexibility in composing/modifying features for customers.

In fact, by following Smalltalk MVC correctly in Rails, you would not need any extraneous "View Component" libraries because you would already be building view components natively as demonstrated by the presenter examples above, which can either be well-factored helpers or partials. Such extraneous libraries only add layers of unnecessary complexity due to a misunderstanding of how to follow Smalltalk MVC correctly in the first place.

p.s. The "region" Helper above can optionally be enhanced to manage the region session state in isolation of any Controller, thus maximizing reuse for the Views relying on that View state (parallel to how desktop Views manage their own on-going state as smart objects). Here is one of several ways to do this:

def region
  session[:region_id] = params[:region_id] if params[:region_id]
  Region.find(session[:region_id])
end

p.s.2 Though "before_filter"s in Rails (more recently "before_action"s) can be used to easily load data in controllers, they still put the onus on the controller to do the data loading by push, requiring developers to add such logic to every conroller that will reuse a particular View partial, and adding complexity to reasoning about the code.

--

If you have any questions, remarks, or corrections, feel free to share in comments.

3 comments:

Unknown said...

Great post,

interesting change to MVC. Thx. It would be interesting to know if you ever have used erector? http://erector.rubyforge.org/

Since erectors views are full ruby objects, this would support this new mvc approach even more.

What do you think?
My blog: http://producloment.blogspot.com/

Andy Maleh said...

Pretty cool. If it proves itself practical enough, it could become an alternative to Haml.

I toyed with the idea a bit a few years ago by building my own DSL interpreter for it, but I never tried it in practical development:
http://andymaleh.blogspot.com/2008/01/xml-is-dead-welcome-glimmerized-xml.html

This addresses a particular weakness in older Markaby:
http://andymaleh.blogspot.com/2008/02/mixed-content-in-glimmerized-xml.html

Anonymous said...

Gosh, what a rebel!

I like the idea presented here but I've seen so much 'view-pushing' code in controllers that it seems very unnatural to see views essentially taking care of themselves.

Perhaps the mindset of most traditional Rails developers is that views are not really there to be re-used -- models and controllers are -- therefore views should b just very 'thin' throwaways? Better not to put too much logic in there.

But I can definitely see great applications of this idea. During your talk I was thinking about a story I was working on where we needed to enable/disable certain options in a pulldown based on a model's state -- all of that logic was in the controller which felt wrong. It would have been cool to put all that stuff in the views or within helpers used by the view.

Calvin.