Clean Module Injection in Ruby

skeeze @ pixabay.com

Monkeypatching is an ugly, ugly thing. Modifying a class at runtime is, at best, a necessary evil. More often than not, it leads to code that is difficult to debug, messy (where do you put monkeypatched code?), and unintuitive. Monkeypatching third party libraries can also leave you vulnerable to updates that unexpectedly break things, which is particularly concerning if an update is related to a security fix.

It’s also exceptionally difficult to test a monkeypatch, especially if you conditionally need the monkeypatch during some situations but not others. For example, you may have an environment variable that determines if a monkeypatch is needed or not. How can you test behavior of the clean code vs the patched code if the patching must occur during the initialization of the application? The difficulties introduced by working with monkeypatching tend to grow exponentially with your requirements.

I experienced this situation recently at work. We use Delayed Job’s Active Record adapter, which by default rides ActiveRecord’s connection to interface with a database. This is fine in the majority of situations, but we have a series of jobs that are extremely read intensive. So, in order to reduce stress on our master database, we established a Rails instance that is connected to a read replica database.

Of course, DelayedJob could no longer handle its queue, since it wasn’t able to write to a read replica database. We weighed a few options, but it was important to be running these jobs off the read replica, and we weren’t able to easily switch to a different queuing system like Amazon SQS or Redis.

We needed DelayedJob to establish a connection to the master database, but we needed ActiveRecord to maintain a connection to the read replica. No one was interested in maintaining an internal DelayedJob ActiveRecord backend, so our options were limited. We ended up agreeing on exploring monkeypatching as an option, provided the implementation was both clean and testable.

It turns out that Ruby modules provide an interesting and flexible interface for injecting monkeypatching into a class. There are several advantages to injecting a monkeypatch via module inclusion:

  1. It separates the monkeypatch logic into a discrete object that allows for easier maintenance (and later removal)
  2. It allows conditional inclusion of monkeypatching based on application logic
  3. It allows you to make calls on class methods before or after the monkeypatch is injected
  4. It makes testing far, far easier and more flexible

Module Injection

You can’t simply define monkeypatched methods in a module and mix them into an existing class due to Ruby’s inheritance hierarchy. A method defined explicitly in a class will be used over a method defined in a module. Thankfully, Ruby offers a post-inclusion hook method on modules that is called immediately after a module is included. This makes overriding this default hierarchy quite easy:

IRB output of module inclusion hook

As you can see, the included method takes the class as a parameter and fires after the inclusion is called. This allows us to manipulate the class, both by calling class methods and monkeypatching it:

IRB output of module inclusion monkeypatching

Now, let’s take a look at how to take advantage of this functionality.

Injecting a Separate Connection for DelayedJob

We can use this functionality to create a clean, testable injection of monkeypatched code into DelayedJob, allowing it to use a separate connection than ActiveRecord.

Let’s get to some actual code. I’ve set up a repo demonstrating this here.

First, we need an initializer to 1) determine if the monkeypatch should be used, 2) load the connection details, and 3) include the module in DelayedJob’s ActiveRecord backend:

Application logic to determine module inclusion

Here’s a look at the module itself. Thankfully, the DelayedJob ActiveRecord backend is well designed and requires a very light touch:

Module implementing monkeypatch injection

Note that two things are happening here. First, we establish the initial connection that DelayedJob uses. Without this, the separate connection would only be established once .after_fork is called. Second, we monkeypatch .after_fork to maintain the connection.

This is all well and good, but how can we be comfortable that this works correctly? Let’s take a look at how we can test this:

Tests for the injection module

Monkeypatching is never really an ideal solution, but I think that extracting this logic into a module is a much cleaner implementation than other possibilities. It allows for cleaner, more generic testing. It allows for extracting the monkeypatch logic into its own file and namespace, which makes maintenance easier. It also allows for conditional injection based on application logic, which makes it a bit more flexible and easier to control.

Like what you read? Give Harry Stebbins a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.