Refactoring a Ruby Module to a Class

Setting up a Few Straw Dog Examples

I was pairing with a developer on some Ruby 📖 code. It looked like we had a class that included a Ruby module from an upstream gem. It looked like the class then defined methods that the module would use. For the specific code, this was not the case. But when looking at a diff that included a line for include SaidModule and some new method definitions, it’s easy to jump to that conclusion.

It is precarious having a module that assumes other methods that the module did not define.

For this blog post, I want to put forward an initial state and offer up two refactoring options.

Straw Dog Example

Below is the straw dog example.

module Mixin
  def process
    notify!(run)
  end

  def notify!(result)
    puts result.inspect
  end
end

class Example
  include Mixin

  def run
    :run
  end
end

The above Mixin module assumes the existence of a #run method…which Example provides.

Refactor: Delegation Shifting Module to Class

In the below example, I’m shifting the Mixin from a module to a class. And instead of including the Mixin, I’m instantiating it and delegating the methods once exposed via the include process.

require 'forwardable'
class Mixin
  # These are the methods that the module once exposed as “public”
  METHODS = %i[process notify!]
  def initialize(context)
    @context = context
  end

  def process
    result = @context.run
    notify!(result)
  end

  def notify!(result)
    puts result.inspect
  end
end

class Example
  extend Forwardable
  def_delegators :@mixin, *Mixin::METHODS

  def initialize
    @mixin = Mixin.new(self)
  end

  def run
    :run
  end
end

The interaction between the two classes becomes a bit more clear; the Mixin class is more clear about its collaborating expectations.

But perhaps the change from module to class is not acceptable. It is a significant break in the expectations.

Refactor: Bi-Directional Delegation with Intermediary Class

In the following example I preserve Mixin as a module. I instead introduce an intermediary class to help expose the interactions between the module and the class.

require 'forwardable'
module Mixin
  def process
    notify!(run)
  end

  def notify!(result)
    puts result.inspect
  end

  class Instance
    extend Forwardable
    include Mixin

    METHODS = %i[process notify!]

    def initialize(context)
      @context = context
    end

    def_delegator :@context, :run
  end
end

class Example
  extend Forwardable
  def_delegators :@mixin, *Mixin::Instance::METHODS

  def initialize
    @mixin = Mixin::Instance.new(self)
  end

  def run
    :run
  end
end

Example.new.process

Conclusion

My preference between the two refactor options is to replace the module with a class. However, if the module is included in several objects, that shift can be harder to determine/understand it’s impact.

The “bi-directional delegation” example provides a step-wise refactor where we disentangle the tightly coupled assumptions of module and the class(es) in which we include it.

In my experience, shifting away from modules into classes helps with unit testing. Modules without their own unit tests are a key ingredient in cooking up spaghetti code.

I still use modules. They have their place but they are easy to abuse. If a module remains narrow in focus, and independent of it’s including class, they are a wonderful short-hand for extending functionality.