Decorators in Ruby

Decorators in Ruby

Arlandis Word

May 18, 2015

Ruby's low-overhead metaprogramming facilities make it easy to create elegant APIs with minimal effort. As a testament to its power, I can say with a high degree of confidence that all of your favorite Ruby libraries leverage metaprogramming in one way or another.

Recently, I found myself needing to add some very simple behavior to each method in a class. For the sake of this post, let's assume our job is to push to an event queue whenever a method starts or finishes.

class Store
  def initialize(db, event_queue)
    @db = db
    @event_queue = event_queue
  end

  def order_purchase(user, items)
    @db.transaction do
      decrease_inventory(items)
      add_to_purchase_history(user, items)
    end
  end

  def decrease_inventory(items)
    # TODO
  end

  def add_to_purchase_history(user, items)
    # TODO
  end
end

There are a couple of different ways to get our desired behavior. The simplest way would be to edit the methods directly.

class Store

  def order_purchase(user, items)
    @event_queue.enqueue(__method__, [user, items], :start)
    @db.transaction do
      decrease_inventory(items)
      add_to_purchase_history(user, items)
    end
    @event_queue.enqueue(__method__, [user, items], :end)
  end

  def decrease_inventory(items)
    @event_queue.enqueue(__method__, [items], :start)
    # TODO
    @event_queue.enqueue(__method__, [items], :end)
  end

  def add_to_purchase_history(user, items)
    @event_queue.enqueue(__method__, [user, items], :start)
    # TODO
    @event_queue.enqueue(__method__, [user, items], :end)
  end
end

This is straightforward and fast. I know, I know. It's tempting to commit this and call it a day. There's a lot of duplication though, and we don't want to start the pattern of adding two lines of code every time we need to add notifcations to a method. I wonder if we can make this cleaner with a little metaprogramming...

First, let's think about how the ideal API would look:

class Store
  #extend Notifier
  #notify_on :order_purchase
  #notify_on :decrease_inventory
  #notify_on :add_to_purchase_history
end

Now, we just need to write the module. It seems like the pattern we're going for is "notify of start, do some stuff, notify of end." I have an idea: We can define methods at the class level, so what if we actually rewrote the methods? We could pull that off with define_method and some aliasing.

require 'securerandom'

module Notifier
  def notify_on(event)
    guid = SecureRandom.uuid
    define_method("#{guid}") do |*args, &block|
      @event_queue.enqueue(event, args, :start)
      begin
        result = __send__("#{guid}_#{event}", *args, &block)
        ensure
          @event_queue.enqueue(event, args, :end)
      end
      result
    end
    alias_method "#{guid}_#{event}", event
    alias_method event, "#{guid}"
  end
end

Things get just a little bit tricky here.

  • Our guid is an attempt at guaranteeing uniqueness, so that we won't override any existing methods.

  • notify_on is at the class level, but define_method creates an instance method on its receiver, which will be whatever class we extend Notifier with. So, the implementation inside of define_method actually becomes an instance level implementation.

  • __send__ is our way of executing the original method. We have to use this since we're working with a string. Note that we pass our "#{guid}_#{event}" because we end up aliasing a few lines down.

  • We have to use ensure because we don't know if an exception could bubble up as a result of using __send__ and we want to guarantee that we will always push the concluding event. [1]

  • Finally, alias_method creates a second reference to the method implementation, meaning we can call it using either reference. We take it a step further and actually rebind our original reference name to our new implementation. This way, the class extending this module doesn't have to do anything to the original method itself to gain the additional functionality.

With that in mind, we can now uncomment the code from our example.

class Store
  extend Notifier
  notify_on :order_purchase
  notify_on :decrease_inventory
  notify_on :add_to_purchase_history
end

Let's see all the pieces in action.

class EventQueue
  attr_reader :messages
  def initialize
    @messages = []
  end

  def enqueue(event, args, state)
    @messages << [event, args, state]
  end
end

class DB
  def transaction
    yield
  end
end

q = EventQueue.new
db = DB.new
Store.new(db, q).order_purchase(:ryan, ["almonds", "avocados"])
require 'pp'; pp q.messages
# [[:order_purchase, [:ryan, ["almonds", "avocados"]], :start],
# [:decrease_inventory, [["almonds", "avocados"]], :start],
# [:decrease_inventory, [["almonds", "avocados"]], :end],
# [:add_to_purchase_history, [:ryan, ["almonds", "avocados"]], :start],
# [:add_to_purchase_history, [:ryan, ["almonds", "avocados"]], :end],
# [:order_purchase, [:ryan, ["almonds", "avocados"]], :end]]

With a little cleverness and a small amount of knowledge about the standard library and the Ruby object model, we were able to write a simple, more intuitive API. It's these relatively simple constructs that make Ruby one of my favorite programming languages.

Tradeoffs

We've traded a complex abstraction for a simple interface. There were a lot of things that we needed to get right for this example. One small slip and we could end up with subtle errors and bugs. Why do it? I think there are two primary benefits: modularity and client-side simplicity.

Now that the notification responsibility is decoupled from the purchase responsibility, there's a higher chance of reuse. This functionality is generic enough that other parts of the codebase may leverage it in the future. Alone, that doesn't explain why we resorted to metaprogramming, which leads me to my next point.

The other solutions we examined worked, but none were quite as nice as the solution we ended up with. We don't have to wrap all of our client code in blocks, and we gain a terse syntax for adding our desired functionality. Whether those qualities warrant our metaprogramming is subjective, but I think there's something to be said for metaprogramming's prolific use throughout the Ruby community.

See Also

[1] Actually, we don't know what will happen as a result of calling enqueue either, but for the sake of simplicity we'll assume that exceptions won't be raised. A more thorough implementation would probably need some verification that both messages made it to the queue or otherwise continue sending them until they do.