Dealing with Legacy Gems You Still Need to Use

When you work with a system developed over the years, you will ultimately encounter a challenge - how to solve dependencies between the pieces you put together. Sometimes you need to use two gems but they depend on different versions of another gem, so you will see in the console e.g. foo -> requires bar ~> 1.0.0, baz -> requires ~> bar 2.1.0. In this post I will propose a solution to this problem, allowing us to use two versions of a gem in our project.


This project needs a maintainer

Once in a while, you can see this message welcoming you on the GitHub library page. You are visiting it to check why bundle doesn't allow you to update one of the gems that is a dependency for a new feature. To your surprise, nothing changed in the repository for quite some time. Now, you have a dilemma. Should you replace the obsolete library to get rid of the problem with its deletion? (if that's even possible at all). Or should you look for a solution which doesn't need package locked in the Gemfile?
 
Bundler could not find compatible versions for gem "foobar"
~ No one wants to see this in their console
 
There is also a third not-so-attractive way to approach this challenge. We can simply import the needed gem to our codebase. I will describe step by step, exactly what you have to do to handle it without a headache.

A good programmer is a lazy one

Very often, when a new task lands on our desk, we start overthinking it. The feeling it will need a lot of effort is settling in. Even so, usually the best way to approach it is from a time-to-delivery perspective. 
 
As you will learn later, library variations will be distinguished by a modified name. Therefore, as part of the implementation process, we will need to overwrite, or monkey patch calls to the imported gem. We can choose which version to import and which one to bundle, there are a few things to consider. Firstly it’s probably sensible to prefer importing the older version so bundle can manage to update the newer gem, however, if the new version of the gem is a dependency of a single gem while the old version is a dependency of 10 gems, you might want to import the new version to save time and effort on monkey patching.

OK, I made the decision, where should I put my stuff?

 
As with many things in the Rails world, here we have convention too. Depending on how conservative you are, place external code either under /app/lib/external or /lib/external. Content included under the app folder will be autoloaded, so just remember to remove all require statements, as they are no longer needed.
 
For the second option, things get a bit complicated. First, you will need to make sure all require statements are modified into require_relative. Then, to initiate loading, create extensions.rb in initializers folder. This will ensure that all the required external libraries are loaded before we make any calls.
 
// extensions.rb

Dir["#{Rails.root}/lib/external/foobar221/*.rb"]
  .each { |file| require file }
 
To avoid namespace conflicts with another gem version living in our bundle, we now need to change module and class names. For example, if our gem in question is called Foobar, append its version number at the end. From this point, every time you would like to use an instance of newly incorporated code, you need to refer to it as Foobar221.

Monkey patching dependent gems

Overwriting gems' code isn't the most honourable thing to do. I assume that if you decided to go this route, there was no other choice. After you localised all the places which need to be amended, you should put all related code in /app/initializers/**baz**_patch.rb. The structure of your file could look like this:
 
//**baz**_patch.rb 

# describe why you are doing it, 
# so your colleagues will 
# hate you a bit less 

***::Baz::***.class_eval do 
  def fetch_data   
    @data ||= Foobar221.call(...)   
    ... 
  end
  ...
end

Make it noisy

What we proposed above can become an enduring solution. By enhancing it with proper testing and comments, we can avoid one worrisome situation where someone will unconsciously get in the way of our implementation, either by using the wrong gem version, or removing our patch as it may seem unused. To prevent both things from happening, we should make it noticeable.
 
Do not hesitate to leave evidence of your decisions in the Gemfile. In the form of comment, tell other developers that they can expect another version of this gem in the application and explain why. The same applies to *_patch.rb file, which gathers all rewritten methods.
 
Lots of companies, including us, use CI as part of the development process, with passing tests guarding merge and deploy activities. In order to guarantee that the proper version of the library is called, prepare meaningful test cases. In the future, they will have to be changed or removed to omit your code, thus making your hidden gem stick out to any even temporary team member.

Here is an example of how you can do it:
 
//**baz**_patch_spec.rb

require 'rails_helper' 

RSpec.describe **::Baz::** do  

  before do    
    ...method stubbing..  
  end   

  describe '.fetch_data' do    
    subject { described_class.new.fetch_data }     

    it 'uses Foobar221 library when fetching data' do  
      expect(::Foobar221).to receive(:call)      
      subject    
    end  
  end
end
 
Going through all the steps mentioned above should provide you with a workable solution to the given problem. Hacking your codebase to have two versions of the same gem isn't commonly seen. And the truth is, in most cases, it should be avoided. Nevertheless, we don't live in an ideal world where we can have all the time needed to deliver perfect code. Therefore, bear in mind that done is better than perfect, and this will work just fine in your project.

Looking for a new challenge? Join Our Team


Like 5 likes
Witek Muszyński
Share:

Join the conversation

This will be shown public
All comments are moderated

Get our stories delivered

From us to your inbox weekly.