GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS

Written by thoughtbot

Rails Refactoring Example: Introduce Null Object

You probably don't write code like this:

if object.kind_of?(User)
  do_this
else
  object.do_that
end

Why not? Because Ruby encourages duck typing and polymorphism.

Smoking duck

A hidden version

Here's the same principle:

if object.nil?
  do_this
else
  object.do_that
else

This checks that object is of type NilClass instead of type User.

The pattern

There's an old pattern called Null Object that addresses this special case of avoiding type-checking in favor of duck typing.

Here's an example of the "Introduce Null Object" refactoring to fix this problem with a Null Object in a Rails app.

The code to be refactored

Airbrake reports an error from this line in a Haml view:

= l location.orders.ascend_by_created_at.first.created_at.to_date

Demeter is displeased but let's fix the problem for our users first.

The anti-pattern quick fix

We could do something like this:

- if location.orders.any?
  = l location.orders.ascend_by_created_at.first.created_at.to_date
- else
  No orders yet

Resist the urge.

A cleaner fix

First, re-create the error in the setup phase of a functional or integration test by creating a location without orders, then making an HTTP GET to the page.

We can change the view:

= l location.first_order_date

Much better. Bonus: we've given the line an intention-revealing name.

This new method doesn't exist yet, though, so let's test-drive it:

context 'first_order_date' do
  setup do
    @location = create(:location)
  end

  context 'when an order exists' do
    setup do
      @date = Date.today
      create :order, location: @location, created_at: @date
    end

    should 'return first order date' do
      assert_equal @date, @location.first_order_date
    end
  end

  context 'when no orders exist' do
    should 'respond to strftime' do
      assert_respond_to @location.first_order_date, :strftime
    end
  end
end

How should the duck respond?

The interesting bit is the 'no orders' case. Instead of returning nil, which caused the original error, we want to return an object that responds to strftime.

Why? Let's look at the view again:

= l location.first_order_date

That l method is an alias for localize, which calls I18n.translate. If we look inside that method, we'll see something like:

def localize(locale, object, format = :default, options = {})
  unless object.respond_to?(:strftime)
    raise ArgumentError
  end

  # ...
end

The localize method expects an object that responds to strftime.

So, to get the unit test green, let's do this in the Location model:

def first_order_date
  if first_order
    first_order.created_at.to_date
  else
    NullDate.new 'orders'
  end
end

private

def first_order
  @first_order ||= orders.ascend_by_created_at.first
end

The Null Object

What's this hipster NullDate object? We'll have to write it.

The failing test for NullDate tests its strftime method:

context 'strftime' do
  setup do
    @null_date = NullDate.new('orders')
  end

  should 'return user-friendly message' do
    assert_match /No orders yet/, @null_date.strftime('anything')
  end
end

The passing implementation:

class NullDate
  def initialize(nullable)
    @nullable = nullable
  end

  def strftime(string)
    I18n.t 'models.null_date.strftime', nullable: @nullable
  end
end

A final touch

Since the intention of this feature is to display copy in the view via the localize method, we localized the response of NullDate#strftime.

In config/locales/en.yml:

en:
  models:
    null_date:
      strftime:
        No %{nullable} yet

Summary

So that works. Is it worth it? I say yes:

  • It looks like a lot more code than the quick fix, but it's quick to test-drive.
  • Since the resulting Null Object is generalized, it has a high chance for re-use. In fact, it should Just Work if you drop it in your Rails app.