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.