Implementing the callback pattern in Ruby

As a Ruby programmer, there are some nifty metaprogramming tricks you can implement to make your code both more flexible, and more readable. One of these tricks, the callback pattern, enables us to turn this:

response = NoteApi.save(note)  
if response.status == 200  
  puts "Saved your note!"
elsif response.status == 401  
  puts "Unauthorized!"
elsif response.status == 500  
  puts "An error occurred!"
end  

...into this:

NoteApi.save note do |on|  
  on.success { |note| puts "Saved your note!" }
  on.unauthorized { |note| puts "Unauthorized!" }
  on.internal_error { |note| puts "An error occurred!" }
end  

In this article, we'll cover:

  • What kind of problem this pattern solves.
  • An example of the callback pattern, and how it works.
  • How you can implement this pattern in your application using the Hollerback Ruby gem.

The conditional problem

Throughout my experience as a Ruby on Rails web developer, I've seen a lots of different ways of accomplishing some common tasks. Universally though, every application at some point does something like this.

def show  
  @note = NoteApi.find(params[:id])
  @success_message = "Note retrieved successfully!" if @note
rescue => e  
  if e.class <= NoteNotFoundError
    @error_message = "Note not found!"
  elsif e.class <= NoteAccessDeniedError
    @error_message = "You do not have permission to access that note!"
  else
    @error_message = "Something went wrong!"
  end
end  

And I'm sure you have too. There's nothing particularly special about this code... it's a controller action that tries to get a Note from an API, and sets some pretty flash messages for the user when something goes wrong. Standard affair for Rails applications, really. The code is simple enough to read, too. So for a basic app, there's not much to do here.

I've implemented this sort of thing hundreds of times in a variety of flavors. But the more I see it, the less I've found myself happy with it. And the taste only becomes more bitter as the complexity of these conditionals begin to grow.

That unhappiness started with the aesthetics. Though it's comprehensible, it's a little ugly. The ifs and elsifs that litter its body forces you to look just a little harder at it, too. Makes you think about the conditions of when one section of code will run, and what the purpose of it is.

And that's hardly a condemnation in itself. Function over style, right?

But beneath its worn and familiar facade, I found something far uglier. It actually introduces a soft coupling between the API, and the function that consumes that API, via the values returned by the API. Often times, those return values are response codes: 200 OK, 404 Not Found or whatever value the API decided to return to signal a specific condition.

And there's a real danger in planting such intimate knowledge into the many components of your application. If the API changes, you might find that the older, now inapplicable knowledge has grown roots and made itself difficult to extract.

Coupling arises from conflating intent with knowledge.

Sometimes knowing about all of the internals of our systems can hurt our design, since it allows us to make assumptions about how something works today, that may not hold true in the future.

In the case of our #show function, the issue is we shouldn't care about how the API works, or make assumptions about its specific responses or errors. And we shouldn't communicate with the API using knowledge we don't particularly care about either (e.g. return values.)

Perhaps we should instead be communicating with intent. When that API signals us back, what we actually care about is if it succeeded or failed, and if it failed, how it failed so we can take the action that matches our intentions.

Exploring the callback pattern

In travels between articles and other discussions about metaprogramming in Ruby, I stumbled upon a fascinating pattern. On Matt Sear's blog, he wrote about a neat little snippet of code he used to implement what he called "dynamic callbacks."

The code basically boiled down to:

class Proc  
  def callback(callable, *args)
    self === Class.new do
      method_name = callable.to_sym
      define_method(method_name) { |&block| block.nil? ? true : block.call(*args) }
      define_method("#{method_name}?") { true }
      def method_missing(method_name, *args, &block) false; end
    end.new
  end
end  

It's a dense, yet clever usage metaprogramming to invoke anonymous code blocks. And so I played with it a bit, and learned to like it more as I got to see it in action.

However, there are some limitations in this implementation:

  1. It relies on monkey-patching Ruby's Proc class to work
  2. Defining methods on an anonymous class is a little hairy (possibly less performant)
  3. There's limited flexibility in how these callbacks can be invoked, and other smaller edge cases (e.g. what if I wanted to optionally raise a StandardError instead of true if a missing callback was invoked?)

The latter two aren't entirely deal-breakers, but the monkey-patching is something that just seems like a bad idea.

Can we do better?

Implementing the callback pattern

On the same blog page, in the comments section, I discovered a cool Gist from Ryan LeCompte which instead used a class that wrapped the callbacks into a single object. This is looking a lot more like what I need. It eliminated the need the monkey-patch Proc (phew!), and opened up options to modify & extend the behavior of callbacks as the application requires.

And so feeling inspired, I decided to add my own modifications for convenience, and here's what I came up with:

module Callbacks  
  class CallbackCollection
    def initialize(block)
      self.tap { |proxy| block.call(proxy) if block }
    end

    def respond_with(callback, *args)
      if callbacks.has_key?(callback)
        callbacks[callback].call(*args)
      else
        raise NoMethodError.new("No callback '#{callback.to_s}'' is defined.")
      end
    end
    def try_respond_with(callback, *args)
      callbacks[callback].call(*args) if callbacks.has_key?(callback)
    end

    def method_missing(m, *args, &block)
      block ? callbacks[m] = block : super
      self
    end

    protected
    def callbacks
      @callbacks ||= {}
    end
  end
end  

At a casual glance, it might be difficult to understand what's going on here, so let's break it down.

Imagine you've defined a class that implements callbacks using the above module like so:

class NoteApi  
  include Callbacks

  def self.find_or_new(note_id, &block)
    callbacks = CallbackCollection.new(block)
    callbacks.respond_with(:new_note, Note.new)
  end
end  

Then you invoked NoteApi with the following:

note = NoteApi.find_or_new "my-note" do |on|  
  on.new_note |note|
    puts "Created a new note!"
    note
  end
  on.existing_note |note|
    puts "Go an existing new note"
    note
  end
end  

Here's what happens:

  1. The code between do |on| ... end matches the &block parameter defined on NoteApi#find_or_new, and becomes a Proc object (basically an anonymous method), which can be accessed as block inside the find_or_new method.

  2. That block now represents a collection of callback methods. At this point, we can't use in its current form to invoke callbacks, as it's too raw. If we invoked block.call(:new_note), it would throw an error saying is missing method #new_note, rather than invoking the #new_note callback.

    We need to turn this block into an object that has our callbacks defined within it as methods we can call at will. To do this, we invoke CallbackCollection.new(block), which will help build us an object we can invoke callbacks from.

  3. In CallbackCollection#initialize, we start to build a CallbackCollection object that has the callback methods in it. This is where the magic starts.

    self.tap { |proxy| block.call(proxy) if block }

    In this line, self is a new CallbackCollection object. We invoke tap on it, which is just some stylistic shorthand for "do some stuff, and when you're done, return the object that called #tap", in our case, our collection object. Inside that call, proxy is the very same collection object, which we use as a parameter when we invoke our callback block.

  4. Now Ruby is evaluating that original do |on| ... end block, where on is our CallbackCollection object. As it runs, it starts invoking #new_note and #existing_note on our collection object. Normally, this would throw a missing method error. However, we implemented a special Ruby method called #method_missing on our class that allows us to catch these bad method calls, and take some kind of special action.

    Our special action in this case is to create a callback on our collection object. We take the method name (m), and the block it passed (e.g. |note| ... end) and we store it in a Hash by method name on our collection object.

  5. At this point, the callbacks are created and stored on the CallbackCollection object, the execution of do |on| ... end completes, and the collection object is returned. callbacks within NoteApi#find_or_new is now an object we can invoke callbacks on as we please.

  6. The next line callbacks.respond_with(:new_note, Note.new) is an example of a callback being invoked. The #respond_with method simply searches the Hash for a key that matches the callback, and invokes the stored callback (e.g. |note| ... end).

  7. Finally, whatever the last execution value is in the callback is the return value of #respond_with. Likewise, if #respond_with is the last operation in #find_or_new, it will use that as a return value.

Playing around with it, you might find there's a lot of flexibility here. You can override, or extend functionality (e.g. the #try_respond_with method) as you see fit.

How you can implement it as a gem using Hollerback

For those who want to try this pattern for themselves, I've rolled the code above into a gem, Hollerback, which adds callbacks that you can use in any Ruby 2+ project.

If you're using Bundler, just add the following to your Gemfile:

gem 'hollerback'  

Then implement it your class with the Hollerback module:

class NoteApi  
  include Hollerback

  def get_note(name, &block)
    # Creates Callbacks object from the block
    hollerback_for(block) do |callbacks|
      callbacks.respond_with(:not_found, name)
    end
  end
end  

And pass it some callbacks:

note = NoteApi.new.get_note("Grocery List") do |on|  
  on.not_found do |name|
    Note.create!(name: name)
  end
end  

If you're writing tests for classes that use Hollerback with RSpec 3+, you can use the rspec-hollerback-mocks gem, which adds the and_callback mock extension to help you test your callbacks.

context "for a class" do  
  subject do
    NoteApi.get_note do |on|
      on.success { "Success!" }
    end
  end
  it do
    expect(NoteApi).to receive(:get_note).and_callback(:success)
    expect(subject).to eq("Success!")
  end
end  

These mocks can be incredibly useful for isolating specific behaviors within your function, allowing you to write leaner, more precise tests.

References

If you want to learn more about callbacks or Ruby metaprogramming, check out these interesting links from around the web:

comments powered by Disqus