ActiveRecord-like Hooks Anywhere?

Lifecycle hooks (before_validate, after_commit, etc) are one of the most useful features of ActiveRecord.

What if I told you - with the power of Ruby meta-programming - that we can add such a facility to any Ruby object?

Let’s start with a PORO (plain ‘ol Ruby object) simply called MyPoro. MyPoro is pretty basic, it just contains a method which prints out the sum of a range:

class MyPoro
  def calculate_sum(lower, upper)
    (lower..upper).to_a.reduce(:+)
  end
end

Now what if we wanted to have a nice message printed to the console, replete with an error if the sum exceeds a certain amount?

class MyPoro
  MAX_SUM = 100
  
  def calculate_sum(lower, upper)
    sum = (lower..upper).to_a.reduce(:+)
    
    puts "Your sum is: #{sum}"
    
    if sum > MAX_SUM
      $stderr.puts "Error: sum can no be greater than #{MAX_SUM}"
    end
    
    sum
  end
end

This is fine… except that calculate_sum is doing more than simply calculating a sum – it’s now concerned with formatting output and determining an error condition. Generally speaking this is work that would be better handled by the caller, but in this case we could argue this is logging.

Can we do better? Let’s think of calculating the sum as some event we want to track, and the result of tracking this activity is logging the details around it. There are other things we may want to do as well - perhaps based on the results we could delegate some work to another object, update a database table, etc, just like one might do with ActiveRecord. The sky’s the limit.

Creating an Event

So how do we do this? Let’s create a sum event…

module Events
  module SumEvent
    module ClassMethods
      def on_sum_event(func=nil, &block)
        return @sum_event_handlers unless func || block
          
        @sum_event_handlers ||= []
        @sum_event_handlers << (func ? method(func) : block)
      end
      
      def invoke_sum_event_handlers(instance)
        return unless @sum_event_handlers.kind_of? Array
        
        @sum_event_handlers.each { |h| h.call(instance) }
      end
    end
    
    def self.included(parent)
      parent.extend(ClassMethods)
    end
    
    def trigger_sum_event
      self.class.invoke_sum_event_handlers(self)
    end
  end
end

Notice that this is a module, not an actual object. The event is really just behavior we are decorating on the including class. While there isn’t much code here, this is some fairly advanced Ruby. Let’s break it down.

First we have a couple class methods, on_sum_event and invoke_sum_event_handlers.

on_sum_event is used by the including class as a way to register an event handler. Note its signature:

def on_sum_event(func=nil, &block)

So it registers either a function or a block, which we see at:

@sum_event_handlers << (func ? method(func) : block)

How does this work? Well, first thing we see is we’re giving preference to a function if one is passed in, and then we’re calling a method called method on it. What on earth is going on?

Ruby has something called a Method object. This allows us to pass a method around like data to be called at a later time. Note that Method’s parent is Object, so this feature applies to just about any common object we might encounter or create ourselves.

If a function is not present but we have a block, then we simply pass back a block to our collection of handlers.

If we happen to register both a method and a block to our collection, what do we see? Jumping ahead a bit, we will be defining the following handlers in MyPoro:

on_sum_event { |instance| puts "Your sum is: #{instance.sum}" }
on_sum_event :sum_warning_handler

Simply calling MyPoro executes the code for the class, which is where the two handlers above are wired up. Calling MyPoro.on_sum_event without a method or block simply returns the handlers we already have.

Inspecting in irb, we see:

$ irb
irb(main):001:0> require './my_poro'
=> true
irb(main):002:0> MyPoro.on_sum_event
=> [#<Proc:0x007feeea8ef680@/my_poro.rb:15>, #<Method: MyPoro.sum_warning_handler>]
irb(main):003:0> 

So there we have it, our blocks are Proc objects and our methods are Method objects. We can invoke either with the same syntax:

my_proc_object.call(params)
my_method_object.call(params)

With that out of the way, invoke_sum_event_handlers should make more sense:

def invoke_sum_event_handlers(instance)
  return unless @sum_event_handlers.kind_of? Array
  
  @sum_event_handlers.each { |h| h.call(instance) }
end

What is instance that is passed in? Well, it is the object instance the event is called on. Remember this is a class method - the handlers are defined at the class level and they apply to all instances, but they are triggered by an instance. As such, we can and should make the instance available to each handler.

This brings us to the final bit of behavior in our SumEvent, which is our instance-level trigger method:

def trigger_sum_event
  self.class.invoke_sum_event_handlers(self)
end

As this is an instance method, we have to call into the class itself to access invoke_sum_event_handlers. Then we pass in self so the handlers have access to the object instance.

Bringing it All Together

Now we have our SumEvent ready to use, we include it in MyPoro and move out our code for printing out the sum to the console and reporting any warnings to standard error:

class MyPoro
  include Events::SumEvent

  def self.sum_warning_handler(instance)
    if instance.sum > MAX_SUM
      $stderr.puts "Warning: sum is greater than max of #{MAX_SUM}. Let's not get crazy folks!"
    end
  end
  
  on_sum_event { |instance| puts "Your sum is: #{instance.sum}" }
  on_sum_event :sum_warning_handler
  
  # other code omitted
end

And we can update calculate_sum to trigger the event:

def calculate_sum(lower, upper)
  @sum = (lower..upper).to_a.reduce(:+)
    
  # Broadcast event
  trigger_sum_event
  
  @sum
end

Nice and clean. Now calculate_sum does not care about what to do when a sum has been calculated, it simply broadcasts an event, which in turn invokes the registered event handlers.

Caveats

The handlers here are synchronous, and because of that there can be performance implications if certain operations are egregiously blocking (ie. network latency when logging to a 3rd party service). We started this post with a comparison to ActiveRecord hooks - those are synchronous for a reason, insofar that certain actions within a hook may need to stop forward motion of the action. You can always background the meat of a handler at any time (see: Just Background It!), or you could write an Async Event.

As well the code for an event here is pretty generic. If you started adding other events, you can see how there would be a fair amount of duplication. While you could add helper funcs to DRY it up, meta-programming is the better answer here, perhaps creating a simple DSL to spec out events. I’ll save that for a future post.

Finally

Feel free to check out an example of this in action, and feel free to reach out with any comments!