Expressive Class Hierarchies through Dynamically-Instantiated Support Objects

When you’re designing an abstract class for the purpose of subclassing—very common when looking at the framework/app divide—it’s tempting to want to throw a whole bunch of loosely-related functionality into that one parent class. But as we all know, that’s rarely the right approach to designing the models of your system.

So we start to reach for other tools…mixins perhaps. But while I love mixins on the app side of the divide, I’m not always a huge fan of them on the framework side. I’m not saying I won’t do it—I certainly have before—but I more often tend to consider the possibility that in fact I’m working with a cluster of related classes, where one “main” class needs to talk to a few other “support” classes which are most likely nested within the main class’ namespace.

The question then becomes: once a subclass of this abstract class gets authored, what do you do about the support classes? The naïve way would be to reference the support class constant directly. Here’s an example:

class WorkingClass
  def perform_work
    config = ConfigClass.new(self)
    
    do_stuff(strategy: config.strategy)
  end

  def do_stuff(strategy:) = "it worked! #{strategy}"

  class ConfigClass
    def initialize(working)
      @working = working
    end

    def strategy
      raise NoMethodError, "you must implement 'strategy' in concrete subclass"
    end
  end
end

Now this code would work perfectly fine…if all you need is WorkingClass alone. But since that’s simply an abstract class, and the nested ConfigClass is also an abstract class, then Houston, we have a problem.

For you see, once you’ve subclassed both, you may find to your great surprise the wrong class has been instantiated!

class WorkingHarderClass < WorkingClass
  class ConfigClass < WorkingClass::ConfigClass
    def strategy
      # a new purpose emerges
      "easy as pie!"
    end
  end
end

WorkingHarderClass.new.perform_work
# ‼️ you must implement 'strategy' in concrete subclass (NoMethodError)

Oops! 😬

Thankfully, there’s a simple way to fix this problem. All you have to do is change that one line in perform_work:

class WorkingClass
  def perform_work
    config = self.class::ConfigClass.new(self) # changed
    
    do_stuff(strategy: config.strategy)
  end
end

Courtesy of the reference to self.class, now when you run WorkingHarderClass.new.perform_work, it will instantiate the correct supporting class, call that object, and return the phrase “it worked! easy as pie!”

Note: in an earlier version of this article, I used self.class.const_get(:ConfigClass), but I received feedback (thanks Ryan Davis!) the above is an even cleaner approach. 🧹

What’s also nice about this pattern is you can easily swap out supporting classes on a whim, perhaps as part of testing (automated suite, A/B tests, etc.)

# Save a reference to the original class:
_SavedClass = WorkingHarderClass::ConfigClass

# Try a new approach:
WorkingHarderClass::ConfigClass = Class.new(WorkingClass::ConfigClass) do
  def strategy = "another strategy!"
end

WorkingHarderClass.new.perform_work # => "it worked! another strategy!"

# Restore back to the original:
WorkingHarderClass::ConfigClass = _SavedClass

WorkingHarderClass.new.perform_work # => "it worked! easy as pie!"

This almost feels like monkey-patching, but it’s really not. You’re merely tweaking a straightforward class hierarchy and the nested constants thereof. Which, when you think about it, is actually rather cool.

Note: the code examples above are written in a simplistic fashion. In production code, I’d move the setup of config into its own method and utilize the memoization pattern. Read all about it in this Fullstack Ruby article. small red gem symbolizing the Ruby language

Skip to content