ruby

Configurable Ruby Modules: The Module Builder Pattern

Michael Kohl

Michael Kohl on

Configurable Ruby Modules: The Module Builder Pattern

In this post, we'll explore how to create Ruby modules that are configurable by users of our code — a pattern that allows gem authors to add more flexibility to their libraries.

Most Ruby developers are familiar with using modules to share behavior. After all, this is one of their main use cases, according to the documentation:

Modules serve two purposes in Ruby, namespacing and mixin functionality.

Rails added some syntactic sugar in the form of ActiveSupport::Concern, but the general principle remains the same.

The Problem

Using modules to provide mixin functionality is usually straightforward. All we have to do is bundle up some methods and include our module elsewhere:

1module HelloWorld
2  def hello
3    "Hello, world!"
4  end
5end
6class Test
7  include HelloWorld
8end
9Test.new.hello
10#=> "Hello, world!"

This is a pretty static mechanism, though Ruby's inherited and extended hook methods allow for some varying behavior based on the including class:

1module HelloWorld
2  def self.included(base)
3    define_method :hello do
4      "Hello, world from #{base}!"
5    end
6  end
7end
8class Test
9  include HelloWorld
10end
11Test.new.hello
12#=> "Hello, world from Test!"

This is somewhat more dynamic but still doesn't allow our code users to, for instance, rename the hello method at module inclusion time.

The Solution: Configurable Ruby Modules

Over the past few years, a new pattern has emerged that solves this problem, which people sometimes refer to as the "module builder pattern". This technique relies on two primary features of Ruby:

  • Modules are just like any other objects—they can be created on the fly, assigned to variables, dynamically modified, as well as passed to or returned from methods.

    1def make_module
    2  # create a module on the fly and assign it to variable
    3  mod = Module.new
    4
    5  # modify module
    6  mod.module_eval do
    7    def hello
    8      "Hello, AppSignal world!"
    9    end
    10  end
    11
    12  # explicitly return it
    13  mod
    14end
  • The argument to include or extend calls doesn't have to be a module, it can also be an expression returning one, e.g. a method call.

    1class Test
    2  # include the module returned by make_module
    3  include make_module
    4end
    5
    6Test.new.hello
    7#=> "Hello, AppSignal world!"

Module Builder in Action

We will now use this knowledge to build a simple module called Wrapper, which implements the following behavior:

  1. A class including Wrapper can only wrap objects of a specific type. The constructor will verify the argument type and raise an error if the type doesn't match what's expected.
  2. The wrapped object will be available through an instance method called original_<class>, e.g. original_integer or original_string.
  3. It will allow consumers of our code to specify an alternative name for this accessor method, for example, the_string.

Let's take a look at how we want our code to behave:

1# 1
2class IntWrapper
3 # 2
4 include Wrapper.for(Integer)
5end
6
7# 3
8i = IntWrapper.new(42)
9i.original_integer
10#=> 42
11
12# 4
13i = IntWrapper.new("42")
14#=> TypeError (not a Integer)
15
16# 5
17class StringWrapper
18 include Wrapper.for(String, accessor_name: :the_string)
19end
20
21s = StringWrapper.new("Hello, World!")
22# 6
23s.the_string
24#=> "Hello, World!"

In step 1, we define a new class called IntWrapper.

In step 2, we ensure that this class doesn't simply include a module by name but instead, mixes in the result of a call to Wrapper.for(Integer).

In step 3, we instantiate an object of our new class and assign it to i. As specified, this object has a method called original_integer, that satisfies one of our requirements.

In step 4, if we try to pass in an argument of the wrong type, like a string, a helpful TypeError will be raised. Finally, let's verify that users are able to specify custom accessor names.

For this, we define a new class called StringWrapper in step 5 and pass the_string as the keyword argument accessor_name, which we see in action in step 6.

While this is admittedly a somewhat contrived example, it has just enough varying behavior to show off the module builder pattern and how it is used.

First Attempt

Based on the requirements and usage example, we can now begin the implementation. We already know that we need a module named Wrapper with a module-level method called for, which takes a class as an optional keyword argument:

1module Wrapper
2 def self.for(klass, accessor_name: nil)
3 end
4end

Since the return value of this method becomes the argument to include, it needs to be a module. Thus, we can create a new anonymous one with Module.new.

1Module.new do
2end

As per our requirements, this needs to define a constructor which verifies the type of the passed-in object, as well as an appropriately named accessor method. Let's start with the constructor:

1define_method :initialize do |object|
2 raise TypeError, "not a #{klass}" unless object.is_a?(klass)
3 @object = object
4end

This piece of code uses define_method to dynamically add an instance method to the receiver. Since the block acts as a closure, it can use the klass object from the outer scope to perform the required type check.

Adding an appropriately named accessor method is not that much harder:

1# 1
2method_name = accessor_name || begin
3 klass_name = klass.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase
4 "original_#{klass_name}"
5end
6
7# 2
8define_method(method_name) { @object }

First, we need to see if the caller of our code passed in an accessor_name. If so, we just assign it to method_name and are then done. Otherwise, we take the class and convert it to an underscored string, for instance, Integer turns into integer or OpenStruct into open_struct. This klass_name variable is then prefixed with original_ to generate the final accessor name. Once we know the method’s name, we again use define_method to add it to our module, as shown in step 2.

Here's the complete code up to this point. Less than 20 lines for a flexible and configurable Ruby module; not too bad.

1module Wrapper
2  def self.for(klass, accessor_name: nil)
3    Module.new do
4      define_method :initialize do |object|
5        raise TypeError, "not a #{klass}" unless object.is_a?(klass)
6        @object = object
7      end
8
9      method_name = accessor_name || begin
10        klass_name = klass.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase
11        "original_#{klass_name}"
12      end
13
14      define_method(method_name) { @object }
15    end
16  end
17end

Observant readers might remember that Wrapper.for returns an anonymous module. This isn't a problem, but can get a bit confusing when examining an object's inheritance chain:

1StringWrapper.ancestors
2#=> [StringWrapper, #<Module:0x0000000107283680>, Object, Kernel, BasicObject]

Here #<Module:0x0000000107283680> (the name will vary if you are following along) refers to our anonymous module.

Improved Version

Let's make life easier for our users by returning a named module instead of an anonymous one. The code for this is very similar to what we had before, with some minor changes:

1module Wrapper
2  def self.for(klass, accessor_name: nil)
3    # 1
4    mod = const_set("#{klass}InstanceMethods", Module.new)
5
6    # 2
7    mod.module_eval do
8      define_method :initialize do |object|
9        raise TypeError, "not a #{klass}" unless object.is_a?(klass)
10        @object = object
11      end
12
13      method_name = accessor_name || begin
14        klass_name = klass.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
15        "original_#{klass_name}"
16      end
17
18      define_method(method_name) { @object }
19    end
20
21    # 3
22    mod
23  end
24end

In the first step, we create a nested module called "#{klass}InstanceMethods" (for example IntegerInstanceMethods), that is just an “empty” module.

As shown in step 2, we use module_eval in the for method, which evaluates a block of code in the context of the module it’s called on. This way, we can add behavior to the module before returning it in step 3.

If we now examine the ancestors of a class including Wrapper, the output will include a properly named module, which is much more meaningful and easier to debug than the previous anonymous module.

1StringWrapper.ancestors
2#=> [StringWrapper, Wrapper::StringInstanceMethods, Object, Kernel, BasicObject]

The Module Builder Pattern in the Wild

Apart from this post, where else can we find the module builder pattern or similar techniques?

One example is the dry-rb family of gems, where, for example, dry-effects uses module builders to pass configuration options to the various effect handlers:

1# This adds a `counter` effect provider. It will handle (eliminate) effects
2include Dry::Effects::Handler.State(:counter)
3
4# Providing scope is required
5# All cache values will be scoped with this key
6include Dry::Effects::Handler.Cache(:blog)

We can find similar usage in the excellent Shrine gem, which provides a file upload toolkit for Ruby applications:

1class Photo < Sequel::Model
2  include Shrine::Attachment(:image)
3end

This pattern is still relatively new, but I expect we'll see more of it in the future, especially in gems that focus more on pure Ruby applications than Rails ones.

Summary

In this post, we explored how to implement configurable modules in Ruby, a technique sometimes referred to as the module builder pattern. Like other metaprogramming techniques, this comes at the cost of increased complexity and therefore shouldn't be used without good reason. However, in the rare cases where such flexibility is needed, Ruby's object model once again allows for an elegant and concise solution. The module builder pattern isn't something most Ruby developers will need often, but it's a great tool to have in one's toolkit, especially for library authors.

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Share this article

RSS

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps