The Ruby Module Builder Pattern allows you to encapsulate customizable behavior — and store state — which can be applied when extending, including, and/or prepending existing objects (i.e. classes or modules). This solves situations where you need a module to be configurable which is not possible by default.
To illustrate, the following provides the Ruby object hierarchy for modules and classes:
Notice that modules and classes are siblings to each other while also children of Object
(via Kernel
). Normally, a Module
is not a superclass of Class
but I added a green dashed line that shows what the hierarchy looks like when a Class
is taught to inherit from Module
. Adding this single line makes this pattern possible and unleashes a ton of potential. The rest of this article will focus on unraveling the power behind the green line that makes this pattern possible.
Quick Start
To get started, we’ll experiment with a primitive implementation of the Pipeable gem for composing a series of steps which can be run in sequence. Below is a Ruby Bundler Inline script you can experiment with locally:
#! /usr/bin/env ruby
# frozen_string_literal: true
# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.
require "bundler/inline"
gemfile true do
source "https://rubygems.org"
gem "amazing_print"
gem "debug"
end
require "logger"
LOGGER = Logger.new STDOUT, formatter: -> level, _at, _id, message { "#{level}: #{message}\n" }
class Builder < Module
def initialize *steps
super()
define_method(:run) { steps.each(&:call) }
end
end
class Demo
include Builder.new(proc { puts "Step I." }, proc { puts "Step II." })
def initialize logger: LOGGER
@logger = logger
end
def call
logger.info { "Running steps..." }
run
logger.info { "Finished!" }
end
private
attr_reader :logger
end
Demo.new.call
When you run the above you’ll end up with the following output:
INFO: Running steps...
Step I.
Step II.
INFO: Finished!
In this pattern’s most basic form, you create a class (i.e. Builder
) that subclasses Module
in order to customize inherited behavior. The power lies in the ability to customize behavior based on the context in which the object is included. In the above example, we create an instance of Builder
— which is both a module and a class — to supply steps (procs) which will be executed via the #run
method.
Basics
We can break the above down further by walking through the basics.
Construction
To implement this pattern, as shown in the example, you must subclass Module
and supply parameters. Otherwise, don’t use this pattern if you don’t subclass or define parameters because this pattern will be wasted effort. To elaborate further, here’s the same example constructor, shown earlier, with code comments:
# Subclass `Module` to enable the Module Builder Pattern.
class Builder < Module
# Customize parameters. In this case, we allow any number of steps.
def initialize *steps
# Message `super` without arguments since `Module` doesn't need them.
super()
# Dynamically create a `run` instance method which iterates all steps.
define_method(:run) { steps.each(&:call) }
end
end
You must always supply super
when subclassing so super()
is used to ensure no arguments are passed up to the superclass since Module.new
only accepts a block and we are not using a block in this situation.
With Module#define_method
, we dynamically create a #run
method at runtime on the receiver. The receiver is our Builder
module which will apply all methods to the class it’s inherited into (i.e. Demo
).
Customization
Returning to our earlier example, you’ll notice we were able to customize Builder
when included in our Demo
class. Here’s the specific code snippet:
class Demo
include Builder.new(proc { puts "Step I." }, proc { puts "Step II." })
end
Normally, as with any module, we’d only use include Builder
but since we are using the Module Builder Pattern, we first initialize Builder
with an array of procs which further enhances the reusability of our Builder
implementation because each object Builder
is included in can customize the steps as desired without losing the core functionality of the Builder
implementation. I’ll elaborate on this further in the Advanced section shortly.
Encapsulation
Full encapsulation is provided by default when using this pattern because our Demo
object only has access to the steps via the #run
method as dynamically added by Builder
upon inclusion. Example:
builder = Builder.new
demo = Demo.new
builder.steps # eval error: private method `steps' called for #<Builder>
builder.run # eval error: undefined method `run' for #<Builder>
demo.steps # eval error: undefined method `steps' for an instance of Demo.
demo.run # "Step I.\nStep II.\n"
Ancestry
The ancestry of each object is descriptive as well:
Builder.ancestors # [Builder, Module, Object, Kernel, BasicObject]
Demo.ancestors # [Demo, #<Builder:0x0000000137d1ed80>, Object, Kernel, BasicObject]
In the case of Builder
, we see Builder
is a descendant of Module
as expected. As for Demo
, we see Demo
is a descendant of an instance of Builder
(hence the 0x0000000137d1ed80
memory address). Normally, when using multiple inheritance, you’d only see the module name Builder
without the instance’s memory address. This is another indication of the Module Builder Pattern in action.
Advanced
With the basics out of the way, let’s discuss the more advanced aspects of this pattern.
Defining Methods
As shown in our initial implementation, we get immediate benefit from using Module#define_method
since this method is available within the context of a Module
or Class
. We then use define_method
to define a new method at runtime on the receiver which is the object that includes Builder
.
By default, methods defined at runtime via Module#define_method
are public. This is why demo.run
is accessible and outputs:
Step I. Step II.
In general, this is not what you want since tighter encapsulation is better. To fix, we can use the private #included
hook. Here’s an updated version of the original implementation:
class Builder < Module
def initialize *steps
super()
define_method(:run) { steps.each(&:call) }
end
def included descendant
super
descendant.class_eval "private :run", __FILE__, __LINE__
end
end
Notice we use the private Module#included
method hook which triggers when using .include
in our Demo
implementation (in this case, included
is a private instance method instead of a class method but I’ll explain more shortly). Then we use .class_eval
to ensure #run
is private. Now when we message #run
, we get a proper exception:
class Demo
include Builder.new
end
Demo.new.run
# demo:36:in `<main>': private method `run' called for an instance of Demo (NoMethodError)
Now that #run
is private, we can use #call
to wrap #run
for full encapsulation.
Anonymity
Another variant of this pattern is to use a module singleton method which dynamically builds an anonymous module for inclusion. Here’s what the implementation looks like if we refactor our earlier implementation:
module Builder
def self.with(*steps)
Module.new { define_method(:run) { steps.each(&:call) } }
end
end
class Demo
include Builder.with(proc { puts "Step I." }, proc { puts "Step II." })
end
This is identical — in terms of functionality — to the original implementation but suffers several problems:
-
Once you add multiple singleton methods to a single module, you end up with a junk drawer of semi-related functionality. You get better encapsulation when subclassing
Module
when building your implementation. -
The debugability of your ancestry is diminished. To illustrate, using
Demo.ancestors
yields:
[Demo, #<Module:0x0000000141df9110>, Object, Kernel, BasicObject]
See the problem? Yep, the anonymous Module:0x0000000141df9110
is what we get when using Module.new
. We can fix this by refactoring the implementation to set a temporary name:
module Builder
def self.with(*steps)
Module.new { define_method(:run) { steps.each(&:call) } }
.set_temporary_name("builder")
end
end
class Demo
include Builder.with(proc { puts "Step I." }, proc { puts "Step II." })
end
Demo.ancestors
# [Demo, builder, Object, Kernel, BasicObject]
Notice builder
shows up in the ancestry instead of the anonymous module from earlier. At least now we have a clue as to where the ancestor came from but creating multiple anonymous modules this way gets tedious fast.
I would not recommend this approach if you can avoid it.
Hooks
There is a lot you can do with module hooks. I won’t cover them all but do want to highlight a few scenarios that might be of interest.
Basics
When using this pattern, the normal class level Module
hooks are instance hooks instead. To illustrate, here’s how you’d normally implement custom hooks for a standard module:
module Builder
def self.extended(descendant) = puts "Extended: #{descendant}"
def self.included(descendant) = puts "Included: #{descendant}"
def self.prepended(ancestor) = puts "Prepended: #{ancestor}"
end
Except, when using the Module Builder Pattern, you need to use instance methods instead of class methods due to inheriting from Module
like so:
class Builder < Module
def extended(descendant) = puts "Extended: #{descendant}"
def included(descendant) = puts "Included: #{descendant}"
def prepended(ancestor) = puts "Prepended: #{ancestor}"
end
The above is easy to forget so remember to drop self
when using this pattern or you’ll find none of your hooks working properly.
Disablement
When using hooks, you can prevent undesired behavior. Here’s an example, used in the Containable gem, which ensures you can’t create containers out of classes:
class Builder < Module
def extended descendant
fail TypeError, "Only a module can be a container." if descendant.is_a? Class
super
end
end
The above does a type check for Class
types and raises a TypeError
if detected. Generally, you don’t want to implement code that checks types but can be handy in specific situations.
Multiple
Depending on your implementation needs, there is nothing stopping you from using a primary hook to handle additional hooks in a single operation. Here’s an example which leverages the included
hook to include and prepend additional modules at the same time:
class Builder < Module
def included descendant
super
descendant.include Comparable
descendant.prepend Freezable
end
end
Without knowing what is implemented in the Comparable
and Freezable
modules, the object ancestry — assuming we included the above into the Demo
class — would be as follows:
puts Demo.ancestors
# [
# Freezable,
# Demo,
# Comparable,
# Builder:0x000000012c48fdc8,
# Kernel
# BasicObject
# ]
As you can see, Freezable
is prepended while Comparable
and Builder
are included afterwards. There is a trade off with this approach as you are adding more objects to the ancestry but can be worth it if the Comparable
and Freezable
implementations are complex or reused elsewhere in your implementation. That said, strive to keep your ancestry to a minimum.
Syntactic Sugar
To improve this pattern further, a bit of syntactic sugar can help by using brackets to enhance the Object API. Example:
class Builder < Module
def self.[](*) = new(*)
end
In this case, single splat forwarding is used but double splats (i.e. **
) or even full argument forwarding (i.e. ...
) would work depending on your needs (see my Ruby Method Parameters And Arguments article for further details). This allows us to use include Builder[step_a, step_b]
instead of include Builder.new(step_a, step_b)
. The use of .[]
is more self describing of the data type desired instead of using .new
. Both work, but I find .[]
to be more elegant. With the above modification, this means you can now do this:
class Demo
include Builder[proc { puts "Step I." }, proc { puts "Step II." }]
end
Much better! Ideally, you’d clean this up further so all of your steps are predefined elsewhere in your application for further reuse. Example:
STEP_A = proc { puts "Step I." }
STEP_B = proc { puts "Step II." }
class Demo
include Builder[STEP_A, STEP_B]
end
You’re not limited to using an array. Hashes work too. Granted, a hash is not appropriate for our current Builder
implementation but we can look at the Containable and Infusible gems since they both implement the Module Builder Pattern while taking arrays or hashes for arguments. Example:
require "containable"
# Creates a new container with a custom registry.
module Container
extend Containable[register: CustomRegister]
register :one, 1
register :two, 2
end
require "infusible"
# Sets up the import by infusing the above container.
Import = Infusible[Container]
class Demo
# Includes our `:one` and `:two` dependencies only that `:two` is aliased as `:other`.
include Import[:one, other: :two]
end
Both the Containable and Infusible gems use .[]
for arrays and hashes instead of .new
. Using .[]
provides a more succinct and elegant syntax which further amplifies the power of this pattern.
Freezing
You can freeze your implementation of this pattern during initialization like so:
class Builder < Module
def initialize *steps
super()
define_method(:run) { steps.each(&:call) }
freeze
end
end
Freezing your implementation is a good performance optimization, so ensure you do this.
Gemification
At a certain point, you might want to extract your implementation as a gem for maximum reusability. Using a tool like Gemsmith speeds up this extraction process. Assuming you are using Gemsmith, your gem skeleton is built, and you’ve named your project Pipeable
, you’ll want to provide a convenient interface. Given our existing Builder
implementation, we can wrap Builder
as follows:
module Pipeable
def self.included(descendant) = descendant.include Builder.new
def self.[](*) = Builder.new(*)
end
Notice I’ve provided two, top-level, singleton methods. The first (i.e. .included
) ensures we can include Pipeable
in any object without arguments. The second (i.e. .[]
), provides an elegant way to supply an array of custom steps. Here’s how this works in practice:
# With default steps or no steps at all.
class Demo
include Pipeable
end
# With custom steps.
class Demo
include Pipeable[step_a, step_b]
end
If we hadn’t made use of .included
, we’d be forced to include Pipeable[]
in the first example which is ugly and unnecessary when a safe default exists. Having .[]
gives us the ability to provide custom steps if desired. Both of these top-level singleton methods are necessary to provide an elegant design to your gem/library when you want default and custom behavior that isn’t awkward and feels natural.
💡 Keep in mind, you don’t have to use both .included
and .[]
. Depending on your implementation, you might only need one or the other but not both and that’s fine too. Also, when providing a top-level module, you don’t have to support Builder.[]
— as mentioned earlier — since this top-level wrapper handles that for you.
Memory/Performance
In terms of memory consumption and/or CPU performance, the only time this comes into play is when the class is loaded. This is a one-time cost only. There is no memory/performance hit when creating instances since they are created after the class is loaded. So you can create as many instances as you want without additional cost or side effects from using this pattern.
As with standard multiple inheritance, nothing is garbage collected. Unless you are loading a large amount of objects into memory when the class loaded, you won’t see a memory spike or memory being retained. Same goes for CPU performance. Unless you are doing heavy computation upon loading your class, you won’t see a spike in CPU activity either.
Guidelines
When naming objects using this pattern, stick with nouns and adjectives:
-
Nouns: Use names that end in
er
oror
likeBuilder
used in the example above. Nouns are what you use when subclassingModule
. -
Adjectives: Use names that end in
able
likeObservable
,Comparable
,Forwardable
as found within the Ruby core libraries. Adjectives are what you use when providing the common interface to your implementation that upstream applications can use. This is especially handy when extracting as a library or gem.
Complete Example
The following is a refactor of our earlier Ruby Bundler Inline script — as shown in the Quick Start section at the beginning of this article — with many of the advanced learnings applied:
#! /usr/bin/env ruby
# frozen_string_literal: true
# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.
require "bundler/inline"
gemfile true do
source "https://rubygems.org"
gem "amazing_print"
gem "debug"
end
require "logger"
LOGGER = Logger.new STDOUT, formatter: -> level, _at, _id, message { "#{level}: #{message}\n" }
class Builder < Module
def initialize *steps
super()
define_method(:run) { steps.each(&:call) }
freeze
end
def included descendant
super
descendant.class_eval "private :run", __FILE__, __LINE__
end
end
module Pipeable
def self.included(descendant) = descendant.include Builder.new
def self.[](*) = Builder.new(*)
end
STEP_A = proc { puts "Step I." }
STEP_B = proc { puts "Step II." }
class Demo
include Pipeable[STEP_A, STEP_B]
def initialize logger: LOGGER
@logger = logger
end
def call
logger.info { "Running steps..." }
run
logger.info { "Finished!" }
end
private
attr_reader :logger
end
Demo.new.call
With the above you can compare the difference between the original script and this updated script or enhance/experiment further as you like.
💡 Don’t forget, you can use the Pipeable gem if you want a fully functional version of this implementation too.
Additional Examples
The following gems use the Module Builder Pattern is you’d like to explore further:
-
Containable: A thread-safe dependency injection container.
-
Infusible: An automatic dependency injector.
-
Pipeable: A domain specific language for building functionally composable steps.
-
Wholable: A whole value object enabler.
The above are advanced examples of everything we’ve discussed in this article.
History
In case you are curious about the long tail of discussion related to this pattern, the following highlights the previous writings of others despite functionality for this pattern existing since the creation of the Ruby language.
-
2012: Peter Solnica wrote about subclassing modules.
-
2016: Eric Anderson wrote about arguments for included modules.
-
2017: Chris Salzberg wrote and spoke about this pattern while being the first to designate a label: Module Builder.
-
2023: Henrik Nyh wrote about discovering the Module Builder Pattern.
⚠️ Regarding Salzberg’s article, please avoid using anonymous Struct
inheritance which is strongly discouraged by the Ruby Core Team. I also write about avoiding this practice in my Ruby Structs and Ruby Data articles. Additionally, use of #send
(or more appropriately #__send__
) to privately send a message breaks encapsulation. Only use #public_send
for dynamic messaging unless within the same object.
⚠️ Regarding Nyh’s article, please avoid using Active Support concerns as suggested in the article. Stick with native Ruby modules because this gives you greater latitude to extract functionality into a library, gem, etc. without being dependent on Rails.
Conclusion
Like any pattern, there are pros and cons. Don’t reach for this pattern if you don’t have anything to configure since a standard module is fine. However, if you do find yourself needing to extract common functionality which can be configured, then this pattern is perfect.
I hope you’ve enjoyed this deep dive in to the Module Builder Pattern. Thanks for reading!