Safe monkeypatching

Ruby is the language of monkey patching. While it's theoretically possible to avoid monkey patching, I'm 99% sure that your app contains at least a few of them (especially of you use Rails).

Monkey patching is usually considered as an anti-pattern, but sometimes it can't be avoided.

The typical use case of monkey patching in a Rails app is a bug fix. If you're using Rails 5 and the bug you're hunting was only fixed in 5.1 which is not released yet, you'd have no other option than to copy-paste the fix and use a monkey patch.

But still, there are good and bad ways to add a monkey patch. This Pull Request introduces a configurable option for dumping a database. Before that patch, there was no way to configure dumping flags. Imagine that we're using an older Rails and we still want to pass a custom flag. We'd have to monkeypatch that class:

# config/initializers/active_record_patches.rb
module ActiveRecordDbCommandPatch
  def run_cmd(cmd, args, action)
    # pass an extra flag to mysqldump
    if cmd == "mysqldump"
      args = args + ["—skip-add-drop-table"]
    end
    super(cmd, args, action)
  end
end
ActiveRecord::Tasks::MySQLDatabaseTasks.prepend(ActiveRecordDbCommandPatch)

Why is this way to monkey patch is not the best? Because when we upgrade to a new Rails version that has a configurable option, we may forget to clean up and this patch will still live in the app. Even worse, imagine that run_cmd method in Rails was refactored and the patch will introduce a bug.

We can improve it by 1) checking that run_cmd is available and 2) that configurable option is not available yet in the current Rails version.

# config/initializers/active_record_patches.rb
if ActiveRecord::Tasks::DatabaseTasks.respond_to?(:structure_dump_flags)
  raise "you're running the Rails version that no longer requires the patch"
end

module ActiveRecordDbCommandPatch
  def run_cmd(cmd, args, action)
    # pass an extra flag to mysqldump
    if cmd == "mysqldump"
      args = args + ["—skip-add-drop-table"]
    end
    super(cmd, args, action)
  end
end

# instance_method will raise with NameError is the method is not available
if ActiveRecord::Tasks::MySQLDatabaseTasks.instance_method(:run_cmd)
  ActiveRecord::Tasks::MySQLDatabaseTasks.prepend(ActiveRecordDbCommandPatch)
end

This way will help you to remove the patch as soon as you update Rails. There is also a way to use the Rails version as an indicator that the patch is no longer necessary:

if Rails::VERSION::MAJOR > 4
  raise "you're running the Rails version that no longer requires the patch"
end

For a large Rails app, it may be impossible to avoid monkeypatches. The best we can do is to inject them carefully, providing a safe way for a patch to be removed when it's no longer necessary.

Happy monkeypaching!

Written in January 2017.
Kir Shatrov

Kir Shatrov helps businesses to grow by scaling the infrastructure. He writes about software, scalability and the ecosystem. Follow him on Twitter to get the latest updates.