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.
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
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
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
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)
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.
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.
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.
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
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).
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.
Since the original publication of this post (and the even older days of using this approach), Ruby has developed other methods of leveraging a
prepend method in particular. See Rails 5, Module#prepend, and the end of `alias_method_chain`.