DEV Community

Cover image for Prepending Modules to the Rescue
Max for bitcrowd

Posted on • Originally published at bitcrowd.dev

Prepending Modules to the Rescue

Ruby's ability to "overlay" default implementations of constants, methods and variables via the prepend or prepend_features method on Module can be a helpful tool when dealing with gems overriding setter methods for attributes.

Scenario 🧫

On a Ruby on Rails project, we were recently migrating from the attr_encrypted to the lockbox gem for encrypting database fields. For live applications which cannot accept hours of downtime, the migration path is a multi-step process which includes a period where both gems are used side by side until all data has been migrated from the old columns to the new columns.

Both gems integrate into Rails models via their own macro-style methods one is supposed to add to the models' class definitions:

class User < ApplicationRecord
  attr_encrypted :email, key: key
  attr_encrypted :phone, key: key

  encrypts :email, :phone, migrating: true
end
Enter fullscreen mode Exit fullscreen mode

Under the hood, both gems then dynamically generate the respective getter and setter methods for the attributes (email, email=, phone and phone= in this example).

In addition to that, we were also overriding the setter methods email= and phone= ourselves to do some normalization on the provided values before assigning them. Combining this with the generated setters from attr_encrypted introduces a lot of fuzz: in what order are the implementations called - if at all - and what does super mean in which context? In order to eliminate all this confusion from the start, we previously decided to make our own implementation the "source of truth" and instead of relying on super calls, just integrate the respective parts of attr_encrypted's implementation into our own:

def phone=(value)
  normalized_number = Normalizers::PhoneNumber.normalize(value)
  self.encrypted_phone = encrypt(:phone, normalized_number)
  instance_variable_set(:@phone, normalized_number)
end
Enter fullscreen mode Exit fullscreen mode

Problem 💥

Now, during the migration phase were we utilize both gems in parallel, our own implementation would of course also need to integrate the internals of both gems in our own overriden implementation. Plus we would need to ensure the attr_encrypted related code is removed again once the migration phase is over 🤯.

This seemed overwhelming and just way too many things to take care of for our own tiny model implementation. Integrating gems should ideally not interfere too much with our own plans to normalize attributes before assigning them. In addition to that, integrating so deeply with a gem, that understanding the code required reading the gems internals beyond the "normal" instructions in the README also comes with a high price on maintainability.

So we needed to find another way to use both gems in parallel while also guaranteeing our values are normalized before assigning and before encrypting them.

Rescue 🚑

A post on the arkency blog describes how Ruby's prepend method on Module can be utilized to override or better "overlay" methods added directly onto the class by a gem. One can prepend an anonymous module inline with the own implementation to either fully override the gem's implementation or just "prepend" one's own implementation and then call super() to still invoke the code generated by the gem.

We simply want to "prepend" our own normalization step before the gems start to do their magic and ideally don't want to get into the details of what they are actually doing. So utilizing super() after the normalization step fits our use case perfectly. In the model class definition, this could look like this:

class User < ApplicationRecord
  attr_encrypted :email, key: key
  attr_encrypted :phone, key: key

  encrypts :email, :phone, migrating: true

  prepend(Module.new do
    def phone=(value)
      normalized_number = Normalizers::PhoneNumber.normalize(value)
      super(normalized_number)
    end
  end)
end
Enter fullscreen mode Exit fullscreen mode

This "overlays" or "prepends" our normalization step before the class' implementation of the setter method, even when it's changed by any of the included gems. So we make sure the value is normalized before it is assigned and the gems' implementation of the setter invoked afterwards without any need for us to fiddle with internal details.

⚠️ Note however, that for the sake of readability and consistency, our model class definition follows Rubocop's Rails Style Guide:

Group macro-style methods (has_many, validates, etc) in the beginning of the class definition.

Following this class layout, our own implementation is sure to be processed after the gems' methods were defined and we are prepending our normalization step before the final definition of the setter method. Ordering the definitions differently in our class would impact the value of super() here.

Cleanup 🧽

While prepending an anonymous module directly inside the class definitions works perfectly fine for our use case here, it still looks very verbose and suspiciously distracting for anyone reading over the model definition. And as we made use of this technique in multiple models within the project, we extracted the boilerplate into a model concern. The goal was to hide the details of the prepending trick while at the same time making the normalization step more visible and explicit to the reader.

We extracted a more general Normalizable concern. We already had multiple normalizer classes in the project. They all follow the same pattern and expose a single normalize class method as their public API. So it just made sense tie the implementation of the Normalizable model concern close to those normalizers:

module Normalizable
  extend ActiveSupport::Concern

  class_methods do
    def normalize(attr, with:)
      prepend(Module.new do
        define_method("#{attr}=") do |value|
          normalized_value = with.public_send(:normalize, value)
          super(normalized_value)
        end
      end)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Utilizing this, we can change the previous example to:

class User < ApplicationRecord
  include Normalizable

  attr_encrypted :email, key: key
  attr_encrypted :phone, key: key

  encrypts :email, :phone, migrating: true

  normalize :phone, with: Normalizers::PhoneNumber
end
Enter fullscreen mode Exit fullscreen mode

This hides the internal details of our own implementation of the setter while still making it explicit that we are doing a normalization on the attribute. Similar to the original implementation inlining prepend with an anonymous module, this approach of course still only works as intended if the normalize macro in the class definition is defined after any other setter methods generated by gems are defined. However, in our case it seemed most fitting to place the `normalize calls at the end of the macros section anyways.

Top comments (0)