GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS

Written by thoughtbot

Renderable Null Objects

We've talked about Null Objects before, and how they can remove unwanted conditionals from your code. I'd like to talk about extending those benefits into your Rails views.

Setting the scene

Recently I've been working on an application that displays a lot of graphs of financial data. A DataSeries can return a graph of its recent history, but only if it has_data?.

# app/models/users/data_series.rb
class DataSeries < ActiveRecord::Base
  def recent_history_graph
    if has_data?
      Graph.new(self)
    end
  end

  def has_data?
    # ...
  end
end

In the view, the graph is rendered like this:

# app/views/data_series/show.html.erb
<% if @data_series.recent_history_graph %>
  <%= render @data_series.recent_history_graph %>
<% end %>

This code isn't too bad, but it could be better. The has_data? check is already happening inside the recent_history_graph method, so the client code doesn't need to know under what conditions a DataSeries can produce a Graph, but it still needs to check if a graph was produced.

There are a lot of graphs in this application, and all these conditionals are cluttering up my view code. They introduce additional coupling between the view and the model; every time I call recent_history_graph I have to be aware of two possible return types.

Null Object to the rescue

By refactoring the code to use a Null Object I can remove all those pesky conditionals once and for all.

First, I'll remove the conditional:

# app/views/data_series/show.html.erb
<%= render @data_series.recent_history_graph %>

Without checking for nil, whatever is returned by recent_history_graph is passed to render, so my tests start yelling at me about trying to render nil. To get around this problem I can change the recent_history_graph method to make sure that it never returns nil:

# app/models/data_series.rb
class DataSeries < ActiveRecord::Base
  def recent_history_graph
    if has_data?
      Graph.new(self)
    else
      NullGraph.new
    end
  end

  # ...
end

So far so good. Now my tests are yelling about a non-existent NullGraph class, so to shut them up I can add an empty class:

# app/models/null_graph.rb
class NullGraph
end

This is where things get really interesting. Instead of passing nil to render I'm passing an instance of NullGraph. My tests reveal the following error:

NullGraph is not an ActiveModel-compatible object that returns a valid partial path.

render can only handle objects that respond to the to_partial_path method. The usual solution to this error is to include the ActiveModel::Conversion module into the class. It provides an implementation of to_partial_path based on the class name. In this case it would return "null_graphs/null_graph". I don't want the default behaviour though; I don't consider a NullGraph to be a first-class object, worthy of its own sub-directory to keeps its views in. I'd rather use the partial "graphs/null". Fortunately, this can be achieved by defining my own to_partial_path method:

# app/models/null_graph.rb
class NullGraph
  def to_partial_path
    'graphs/null'
  end
end

We're getting close! The tests are now complaining about a missing partial. The partial itself doesn't need any code, but it's nice to leave a comment there to guide future developers who stumble across a blank partial and wonder what on Earth is going on:

# app/views/graphs/_null.html.erb
<%# Blank partial for rendering NullGraph objects %>

Run the tests one more time, and we're back to green with no more conditionals cluttering the view code. Ah, that's better.

There's more that could be done: The logic for deciding what kind of Graph to return probably doesn't belong in DataSeries, but I'll save that refactoring for another day.