What is Module Builder pattern?
It's a nifty little technique where instead of including a static, predefined module, we include/extend/prepend a module that is returned by some method, the builder call:
# static include
include MyModule
# built module include
include MyModuleBuilder.call(args)
The benefit of introducing this one step of indirection is that now we have a place to customise the module, and document and enforce the contract between the module and the including structure.
Eliminate secret API
Existing articles (see below) do a great job explaining how using a module builder gives flexibility to what module is built and included, but for me the unsung winner feature is that it allows specifying otherwise implicit relationships between the module and the includer. Or as Chris Salzberg put it
Encapsulate this in the place where it best belongs: in the module itself.
Codebases I've been working with have been littered with code like this:
module EmailInvites
def send_invite
EmailSender.call(email: self.email, content: content, delay: DELAY)
end
end
class User < ApplicationRecord
DELAY = 5.minutes
include EmailInvites
end
Yes, we spec #send_invite
(usually with a shared example, shudder) and it works, but we've completely omitted crucial details about the relationship that are necessary for easy maintenance and extension of existing logic, namely:
- We haven't specified anywhere that the including class must implement
#email
, and that it's expected to be a string value that will be used as recipient email - We haven't specified that the module depends on a constant
DELAY
being defined
Using a Module Builder can help make these requirements explicit and self-documenting. Let's specify what the module needs in the inclusion call:
class User < ApplicationRecord
DELAY = 5.minutes
include EmailInvitesModuleBuilder.call(for: "User", email_getter: :email, delivery_delay: DELAY)
end
Would be great to verify that User does indeed implement #email
, but as far as I know, this can only be achieved if the module inclusion is done after the method definition, which goes counter to class content order recommendations. YMMW.
Now for the builder definition. Admittedly, it becomes more complex, because now there are more code scopes to keep track of, but let's see how we get on.
module EmailInvitesModuleBuilder
# @param email_getter [Symbol] which method to call on includer instances to obtain email
# @return [Module]
def self.call(includer_name:, email_getter:, delivery_delay:)
# doing some at-mix-in validating
if includer_name.first.upcase != includer_name.first
raise ArgumentError.new(":includer_name '#{includer_name}' must be titlecased")
end
mod = const_set("#{includer_name}EmailInvites", Module.new)
# fill module
mod.module_eval do
define_method(:send_invite) do |content|
{email: send(email_getter), content: content, delay: delivery_delay}
end
end
# explicitly return it
mod
end
end
And now the often missing part, how to spec this. Follow David Chelimsky's advice
We're interested in specifying two fundamentally different things:
- the behaviour of M in response to being mixed-in
- the behaviour of each class/object that mixes in M in response to events triggered by their consumers
describe EmailInvitesModuleBuilder do
describe ".call(includer_name:, email_getter:, delivery_delay:)" do
subject(:invite_module) { described_class.call(**options) }
let(:options) { {includer_name: includer_name, email_getter: email_getter, delivery_delay: 5} }
let(:email_getter) { :address }
describe "at-mix-in behaviors" do
context "when an invalid :includer_name is provided" do
let(:includer_name) { "user" }
it "raises a descriptive argument error" do
expect { invite_module }.to raise_error(ArgumentError, ":includer_name 'user' must be titlecased")
end
end
end
describe "runtime behaviors granted by including built module" do
let(:includer) do
Class.new do
include EmailInvitesModuleBuilder.call(includer_name: "User", email_getter: :address, delivery_delay: 5)
def address
"a@a.com"
end
end
end
it "defines correctly configured #send_invite" do
expect(includer.new.send_invite("yay")).to eq(email: "a@a.com", content: "yay", delay: 5)
end
end
end
end
For modules intended for use in extend
and that grant "macros" - class methods that will further configure things - specs will have to go one step deeper and show various different ways extending classes could be calling the macro(s), and what effects that will have on instances.
Further reading
- Michael Kohl's "Configurable Ruby Modules: The Module Builder Pattern"
- Chris Salzberg's "The Ruby Module Builder Pattern"