DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Jeremy Friesen for The DEV Team

Posted on • Originally published at takeonrules.com on

Responsible Monkey Patching of Ruby Methods

Steps for Patching in a Responsible Manner

I previously published this in at a different site, but I think it remains helpful for those working in Ruby.

In Ruby, objects and classes are open for extension. Even after you’ve declared a class, that Ruby class and (it’s instantiated objects) continue to be open for extension (e.g. adding new methods, including new modules, etc.).

Rails leverages this to great effect (e.g. 2.days.ago). Knowing this can help you shore up a problem.

The following example provides a tool in your Ruby toolkit to perhaps help you avoid creating a fork of an upstream dependency. In an ideal world, you’d be able to submit a patch to the upstream project, wait for the next release, and then update your local machine.

However, the ideal is not always available. So I want to walk through an approach that I’ve used a handful of times.

Example of Extending

Nestled deep in a dependent gem, you have a method you need to change. For some reason the implementation details are inadequate for your needs.

The Setup

In the dependent gem, using pure Ruby you might see something like this:

module Deep
  module Gem
    module Base
      def the_method_to_change
    # Business logic that isn't quite in line with your business logic.
      end
    end
  end
end

module Deep
  class Object
    # Adds the **instance methods** of the extended module as class methods to
    # this object. `Deep::Object.the_method_to_change` works but
    # `Deep::Object.new.the_method_to_change` would raise a MethodMissing Error
    extend Deep::Gem::Base
  end
end

Enter fullscreen mode Exit fullscreen mode

In Rails world, leveraging ActiveSupport::Concern, you might see the following:

module Deep
  module Gem
    module Base
      extend ActiveSupport::Concern
      class_methods do
    def the_method_to_change
      # Business logic that isn't quite in line with your business logic.
    end
      end
    end
  end
end

module Deep
  class Object
    # Note, we are using `include` instead of `extend`, but the effect is the
    # same; `Deep::Object.the_method_to_change` works but
    # `Deep::Object.new.the_method_to_change` would raise a MethodMissing Error
    include Deep::Gem::Base
  end
end

Enter fullscreen mode Exit fullscreen mode

In your application you may be stuck calling Deep::Object.the_method_to_change yet want to update the underlying implementation. In the real-world example, we wanted to override the behavior of ActiveFedora’s .reindex_everything method.

Implementation

I recommend that if you want to change the method implementation, you make another module and extend that base class. And follow the implementation pattern of the method you are overriding - use ActiveSupport::Concern if the upstream module uses it.

require 'deep/object'
unless Deep::Object::VERSION == '1.0.1'
  raise "Verify this override is still needed for non 1.0.1 versions"
end
module MyNamespace
  module Overrides
    extend ActiveSupport::Concern
    class_methods do
      def the_method_to_change
      end
    end
  end
end

Deep::Object.include(MyNamespace::Overrides)

Enter fullscreen mode Exit fullscreen mode

The above override has a few concepts:

Explicit Require
ensure the upstream class definition is loaded.
Provide Guidance
some guidance that the assumed override only works for version 1.0.1.
Separate Module
to provide structure and further opportunity to document.

Explicit Require

This might be self-evident, but I want to reiterate - if you are replacing an upstream method, make sure that the method to replace is declared before you begin replacing it.

Provide Guidance

In the days of yore, I found and patched a Rails bug. I was working in Rails 3.0.x and used the above approach.

I wrote my module with a Rails version check. Each time I bumped the Rails version and rebooted Rails, the file would raise an exception saying β€œGo check if this fix has been applied.”

For a year or so, I walked that patch along until one day in Rails 3.2.0, the patch was in the main branch. I deleted the file and went about my other work.

When you add that β€œmonkey patch” file, provide context to why you are making the change; What assumptions are in play? Raise an exception if those assumptions are not valid.

  • Provide guidance on how to check this assumption
  • Add comments on why you are doing this
  • Add information in your commit messages describing why
  • And for all that is holy and sacred, write some tests that confirm your expected behavior.

Separate Module

Create a separate module; This allows documentation on the nature of the module. Maybe the module contains interrelated overrides for multiple classes; Or you have a single override. Regardless, this gives a place for people to expect changes.

By mixing in another module, you preserve access to the super method. In the above example, I could add to the the_method_to_change definition a call to super and it call the original Deep::Object.the_method_to_change method.

An Inadequate Implementation

You can see an β€œin the wild implementation” in an application I once helped maintain. I added the config/initializers/active_fedora_soft_delete_monkey_patch.rb file to contain the logic for soft-deletes. The implementation details spanned two inter-related gems, but the logic in our application was inter-related. And for those keeping score, I didn’t quite follow all of my own advice.

I’m not saying that the best place for these changes is in an initializer, but I do believe you should put them in a discoverable place (where-ever that might be in a large code-base).

Conclusion

Knowing the capabilities of your language can help you address a problem in the immediate moment and equip you to best β€œown” that short-cut.

The collective Ruby community has spilled a lot of digital ink posting about Extend vs Include and the nuances. Some posts to consider:

And a personal favorite by Jay Fields for alternatives ways to redefine Ruby methods. Seriously this blog post was what hooked me on Ruby; Methods are detachable and re-attachable lambdas.

Postscript

Since the original publication of this post (and the even older days of using this approach), Ruby has developed other methods of leveraging a module. The prepend method in particular. See Rails 5, Module#prepend, and the end of alias_method_chain.

Top comments (3)

Collapse
w3ndo profile image
Patrick Wendo

So I was busy on twitter when I came across refinements in Ruby and they mention that

Due to Ruby's open classes you can redefine or add functionality to existing classes. This is called a β€œmonkey patch”. Unfortunately the scope of such changes is global. All users of the monkey-patched class see the same changes. This can cause unintended side-effects or breakage of programs.

This reminded me of this particular article and my question here is that would the use of refine have helped in your particular use case?

Collapse
jeremyf profile image
Jeremy Friesen Author

Yes. Refinements alter the implementation details, but the method of documenting and testing would remain similar.

Collapse
w3ndo profile image
Patrick Wendo

Good to know

πŸ€” Did you know?

Β 

️⃣ DEV has a variety of tags to help you find the content you like. Find and follow your favorite tags