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
Mixin module assumes the existence of a
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
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.