Refactoring Ruby: From Subclass to Registry

From developing a solution to gradually improving it by listening to the pain

My team was given a particularly laborious task: implement responsive images throughout a legacy Rails application. Back then, the app was sending disproportionally large image files over to web browsers regardless of client device and viewport characteristics. Users had to bear the burden of prolonged load times and an overall bad experience which contributed towards decreasing conversion rates.

To solve this problem, we provisioned an on-the-fly image server to perform image manipulations in real time and wired it up to a CDN layer. Our first task was to assess whether the concept would work on a small portion of the product, which was far away from the home page and thereby did not have as much traffic.

The first version

With all the basic infrastructure in place, we looked for gems to help us implement responsive images but none proved to be suitable for our use case. We then started developing our own solution to fulfill the following requirements:

  • One Image (AR model) maps to one responsive image (one-to-one).

  • Responsive images can have different formats: HTML, CSS and JSON.

  • Formats can be shared among internal and external apps for preloading, JS integration and other purposes.

If you are wondering, a responsive image consists of a set of images where each one applies to a different screen size or device-pixel-ratio.

After some work, a ResponsiveImage class similar to the following was born:

class ResponsiveImage
  class << self
    attr_reader :versions

    def inherited(subclass)
      subclass.version ImageVersion.new(:original)
    end

    def version(*args)
      new_version = ImageVersion.new(*args)
      (@versions ||= {}).merge!(new_version.id => new_version)
    end  
  end

  version ImageVersion.new(:original)

  def initiliaze(image)
    @image = image
  end

  def url(version_id)
    # Generates a URL corresponding to a version_id
  end

  # Methods to extract representations...
end

As you may have figured, this class aims to provide functionality for subclasses to define and gather image versions:

class FreebieMainImage < ResponsiveImage
  version :sm, 173, 130, [nil, 599]
  version :md, 241, 181, [600, 991]

  # More versions...
end

Take line 2 for example: it declares an image of 173×130 pixels which is meant to be displayed on viewport widths between 0 and 599 pixels. Also, the inherited hook automatically declares an :original version on all subclasses. Among other utilities, each version is meant to generate a distinct URL to the image server requesting a crop corresponding to the declared dimensions:

# Returns a URL for the :sm version
responsive_image.url(:sm)

In the end, a ResponsiveImage object is nothing but a proxy for obtaining different representations, which functionality gets delegated away to specialized objects:

class ResponsiveImage
  # ...

  def to_picture
    PictureTag.new(self)
  end

  # ...
end

Finally, here is an example usage:

<%= FreebieMainImage.new(freebie.main_image).to_picture %>

This ERB code outputs a picture tag HTML containing display rules and URLs for all versions declared in FreebieMainImage.

A touch of convenience

That implementation proved to be a great fit for our problem, but using it was not without friction. Among other issues, we needed to reuse the same responsive image over a single endpoint, and instantiating objects more than once did not feel right.

Using the controller for this purpose would have been a bad move since it would encourage bloating all others:

class FreebiesController
  def show
    freebie = Freebie.find(params[:id])

    @freebie = FreebiePresenter.new(freebie)
    @main_image = FreebieResponsiveMainImage.new(@freebie.main_image)
  end
end

We needed an abstraction to make this recurring code easier to work with. After thinking for a while, we came to the conclusion that a factory to group responsive images by model seemed like an appropriate solution.

Here’s the sketch: given our Freebie model has one main_image, our collection automatically figures the right presenter for it (FreebieMainImage) and delivers the result through a hash-like interface.

Let’s apply some programming by wishful thinking and picture how our imaginary code ought to be used:

# Reads "freebie" images and maps them to the right responsive images
collection = ResponsiveImageCollection.new(freebie)

# We can now use our final responsive image product
puts collection[:main_image].to_picture

Finally, here’s the implementation:

class ResponsiveImageCollection
  def initialize(model)
    @model = model
    @images = {}
  end

  def [](image_id)
    @images[image_id] ||= begin
      image = @model.public_send(image_id)
      klass = find_responsive_image_class(image_id)

      klass.new(image)
    end
  end

  private

  def find_responsive_image_class(image_id)
    "#{@model.class.model_name.name}#{image_id.to_s.camelize}".constantize
  end
end

As you can see, we are using a Railsy convention to find the responsive class of an image: if the model name is Freebie and the image name is main_image, we then look for a class named FreebieMainImage.

The following method at FreebiePresenter makes the code even more accessible and avoids further pollution of controllers:

class FreebiePresenter
  # ...

  def images
    @images ||= ResponsiveImageCollection.new(self)
  end

  # ...
end

Finally, here’s how to use it in the views:

<%= @freebie.images[:main_image].to_picture %>

Advantages of the first version

The launch gone smooth and the code was technically adequate, because:

  • Concerns were kept separate and we did not bloat the model with responsive configuration. That would be more akin to a classic Rails solution.

  • The ResponsiveImage class was not coupled to any particular Image.

  • The version method proved to be a great and nifty macro.

  • Although palliative, ResponsiveImageCollection fulfilled the needs of the first launch: it mapped resources one-to-one but it did not couple the core system in any way.

Good enough, ship it!

The second version

The time came around to implement the system in a wider range of pages, right after our successful POC got deployed. Additionally, adapting the core was necessary to take into account a newfound complexity:

  • One Image maps to many ResponsiveImage and vice-versa (many-to-many).

We did some planning and went on to work on these new changes.

Realizing the symptoms

During our first iteration, we repeatedly found the need to declare responsive subclasses:

class SaleIndexImage < ResponsiveImage
  # Versions...
end

class SaleMainImage < ResponsiveImage
  # Versions...
end

class SaleFooImage < ResponsiveImage
  # Versions...
end

class SaleBarImage < ResponsiveImage
  # Versions...
end

# And a lot more... really!

Then an obvious realization emerged: creating a class for each and every responsive image would get exhausting pretty quickly.

We did not anticipate the pain because at first there were just a few configurations to deal with. This is OK: we released the minimal amount of code to solve our problem, but the solution had to evolve to accommodate newfound requirements and eliminate rough edges.

After asking a few questions to ourselves, an interesting fact turned up: the role of our subclasses was to gather and centralize data.

Wait a minute, isn’t inheritance mostly about behavior?

That’s right: inheritance is best for specializing behavior where a “Y is a X” kind of relationship exists. Of course, our subclasses did not meet those requirements: instead they acted as configuration hubs supplying data over to inherited methods.

Never should the role of inheritance be just to share methods and implementation details. This kind of misuse usually points toward design improvements.

As a side effect, our approach generated complexities not apparent at first sight such as looking for artifacts by class name. This is OK when dealing with SOLID-oriented code, but it did not happen to be our case.

The solution

Our problem seemed more suited to a registry that provides configuration to a factory. Didn’t get it? So bear with me.

The first step to refactor our solution was to start moving versions from class-level to instance-level, though not removing the class method yet:

Great, everything worked just as before and it was possible to instantiate responsive images on-the-fly without the bureaucracy of subclasses:

freebie_main_image = ResponsiveImage.new(freebie.main_image,
  ImageVersion.new(:sm, 173, 130, [nil, 599]),
  ImageVersion.new(:md, 241, 181, [600, 991])
])

Since we started moving versions into an instance method, the next step was to change all call-sites from class method to instance method (we will not present this step here).

However, we were still using subclasses:

FreebieMainImage.new(freebie.main_image)

Eliminating these artifacts proved to be a bit more difficult. First of all, we created a registry to allow getting rid of subclass-centric configuration:

class ResponsiveConfig
  def self.config
    yield instance
  end

  include Singleton

  def initialize
    @config = { default: [] }
  end

  def add(id, versions)
    @config[id] = versions.map { |args| ImageVersion.new(*args) }
  end

  def fetch(id)
    @config.fetch(id)
  end
end

Then we moved versions of all subclasses onto it. For instance, the following code can be placed on a Rails initializer:

ResponsiveConfig.config do |config|
  config.add :freebie_main_image, [
    [:sm, 173, 130, [nil, 599]],
    [:md, 241, 181, [600, 991]]
  ]

  config.add :freebie_hero_image, [
    [:sm, 173, 130, [nil, 599]],
    [:md, 241, 181, [600, 991]]
  ]
end

As you can see, the add method receives a configuration ID and an array of version information which gets mapped away to Struct objects.

Next, we changed ResponsiveImageCollection to search for config keys instead of subclasses:

Finally, we were able to definitely delete ResponsiveImage.version and ResponsiveImage.inherited methods. Our class got much simpler:

But we were not over yet.

One last coupling

Refactoring our solution was relatively easy, but there were still problems. What if we wanted to reuse the same responsive configuration twice? The short answer is we couldn’t, because our collection class was using a hardcoded naming scheme to look up configurations.

For instance, if we had a Project model and wanted to declare versions for its main_image, we would have to use a project_main_image key:

ResponsiveConfig.config do |config|
  config.add :project_main_image, [
    # Versions here...
  ]
end

This was inflexible and did not scale up, not to mention the naming scheme was obscure and hard to remember.

What if we had a way to explicitly map images to their responsive counterparts? In that case, we would be able to specify and repeat any combination and quantity of elements.

Firstly, we created a CollectionConfig class similar to ResponsiveConfig:

class CollectionConfig
  include Singleton

  CollectionMapping = Struct.new(:id, :responsive_config_id, :image_name)

  def self.config
    yield instance
  end

  def initialize
    @config = { default: [] }
  end

  def add(id, mappings)
    @config[id] = mappings.map { |args| CollectionMapping.new(*args) }
  end

  def fetch(id)
    @config.fetch(id)
  end
end

Then we configured our collection mappings like so:

CollectionConfig.config do |config|
  config.add :freebie, [
    [:main_image, :freebie_main_image, :main_image],
    [:hero_image, :freebie_hero_image, :hero_image]
  ]
end

And finally, we changed our collection class to interpret these mappings:

Awesome! In doing that we were finally able to reuse the main_image config on a random_image (or any other image for the matter):

CollectionConfig.config do |config|
  config.add :freebie, [
    [:random_image, :freebie_main_image, :random_image],
    [:main_image, :freebie_main_image, :main_image],
    [:hero_image, :freebie_hero_image, :hero_image]
  ]
end

Using the system was still very easy, not to mention the ERB call sites did not change a tiny bit:

<%= freebie.images[:main_image].to_picture %>

Of course, our collection class could have been made more flexible. For example, we could have gotten rid of the model coupling: why does a collection need to be coupled to a model anyway?

However, do we need to? Nope, it’s a feature! Other kinds of collections can easily be created, and the underlying system is flexible enough to allow for just that.

Wrap up

The code in here was considerably changed to fit into main theme of this blog post. Also, we did not go over the real complexity faced while implementing the solution.

Nevertheless, the key ideas here are:

  • If the amount of change you need to perform on a task is huge, tackle it in small chunks and start small.

  • Don’t try to guess the future: write just the amount of code your solution needs. Listen to the pain and allow your system evolve naturally.

  • Inheritance is not bad, but it’s also not suited for many problems.

  • Beware when subclasses are used with the sole purpose of sharing data. Be attentive to their best usages and keep asking questions to yourself.

Thanks for reading and have a great day.

Thanks to Luiz Rogerio.

We want to work with you. Check out our "What We Do" section!