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.