Module Builder pattern in Ruby

Augusts Bautra - Sep 25 '23 - - Dev Community

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .