In Progress
Unit 1, Lesson 1
In Progress

Module Builder Pattern – Part 2

In the preceding episode, Chris Salzberg showed us how modules are Ruby objects just like any other. And just like other kinds of objects, we can make new classes of module. In today’s episode, he shows us how to apply this knowledge to implement a foundational Ruby metaprogramming technique: the Module Builder pattern.

Video transcript & code

The Module Builder Pattern (Part 2)


class LoggerBuilder < Module
  def initialize(method_name)

Hi everyone, welcome to the second part of this series on the Module Builder Pattern. In the first episode, we got as far as imagining a module builder named LoggerBuilder.

We didn't actually fill in the details of the initializer, so let's do that now.


class LoggerBuilder < Module
  def initialize(method_name)
    # define module methods...

We determined that this module builder should take the method to be logged, method_name, as its argument.

Inside this initializer, we want to define a method override with this name, so that classes that include it will log calls to that method. We need to do this dynamically, since we don't know the name of the method until runtime.

How can we do that?


class LoggerBuilder < Module
  def initialize(method_name)
    def method_name

You might think we could use our good old friend def here. def defines methods, and that's what we need to do, right?

But wait, is this going to work? Can we just use the variable method_name in a call to def like this?

As it turns out, we can't. This code here would actually define a method literally named method_name. What we really want is to define a method whose name corresponds to the value of the method_name variable.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do

And lucky for us, there's a way to do that!

Remember that we're subclassing the Module class here. That means that we get access to all its instance methods in the initializer.

And as it turns out, the Module class has one particular instance method that's going to come in handy here, called define_method.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do
      # method body

define_method is basically like def, except that it's dynamic: you pass the name and body of the method you want to define as arguments to it, and it defines a method for you. The first argument becomes the name of the method, and the block becomes the method body.

We know the name is held in the method_name variable, but what about the block? What do we put in there?

Let's take a step back for a second.


module Loggable
  def make_payment(amount)
    puts "making payment with: #{amount}..."
    super
  end
end

Here's the Loggable module from the last episode. Remember this was our attempt at extracting the logging pattern into a module.

The make_payment method is what we want our builder to build dynamically, so the body of this method is what we want to put in the block.

The block should take an argument, ...

... log the value of that argument,

... and call up to the original method with super.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do
      # method body

Ok, let's see if we can do that with define_method.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |amount|
      # method body

First, let's give the block that argument. It doesn't really matter what we name the argument, so let's keep things concrete and use the same name, amount.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |amount|
      puts "making payment with: #{amount}..."

Now let's add the logger code, exactly it appeared in the Loggable module.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |amount|
      puts "making payment with: #{amount}..."
      super(amount)

And now we add the call to super, as we had it in the Loggable module.

Well, almost as we had it. A quirk of Ruby is that inside define_method, unlike inside def, you need to be explicit about the arguments you're passing to super.

So in this case, we need to be explicit that we are passing amount as the only argument.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |amount|
      puts "making payment with: #{amount}..."
      super(amount)
    end
  end
end

And here we have it, our first implementation of a module builder! It has an initializer which takes a method name as its argument. From that name, it defines an override on the module for logging method calls.


class PaymentsApi
  def make_payment(amount)
    # code to call API
  end
end

Let's take this thing for a spin and see if it actually works.

Here's our original code from the first example in the last episode. We have a payments API method named make_payment that we want to log calls to.


class PaymentsApi
  def make_payment(amount)
    # code to call API
  end
end

PaymentsApi.prepend(LoggerBuilder.new(:make_payment))

Now we prepend an instance of this builder, as we did with the Loggable module. This adds the override to the make_payment method.


PaymentsApi.new(...).make_payment(100)

And now, if we create an instance of the API and call make_payment...


PaymentsApi.new(...).make_payment(100)
#=> making payment with: 100...

We see that it works! It does exactly the same thing as our previous Loggable module did, but without the method name being hard-coded.

OK, now for the real test: let's see if it works with those other classes and methods.


class ApiResponse
  def initialize(status, json)
    # code to initialize response object
  end
end

Here's our ApiResponse class from the last episode, whose initialize method we'd like to log in the same way.


class ApiResponse
  def initialize(status, json)
    # code to initialize response object
  end
end

ApiResponse.prepend(LoggerBuilder.new(:initialize))

We prepend an instance of LoggerBuilder, configured to log the initialize method. Does this work?


ApiResponse.new(200, { foo: 'bar' }.to_json)
#=> ArgumentError: wrong number of arguments (given 2, expected 1)

Uh-oh, we have a problem. Our logger has broken our production code! That's not good.

The error we're seeing is an ArgumentError complaining about the number of arguments. What's going on?


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |amount|
      puts "making payment with: #{amount}..."
      super(amount)
    end
  end
end

Here's the LoggerBuilder again. Notice that the block to define_method takes only one argument, amount.

That worked fine for our payments API class, but the ApiResponse initializer takes two arguments, not one, so we'll have to make this a bit more flexible.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args|

If we want this module builder to work with any method on any class, it'll have to accept anything that can be passed to it. We can handle that with a splat, which expands to any number of arguments.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args|
      puts "calling #{method_name} with #{args}..."

And since we're generalizing this builder, we probably shouldn't be hard-coding "payment" in the log message. Let's make the message a bit more general, by including the method_name variable in the message as well as the entire array of arguments.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args|
      puts "calling #{self.class}##{method_name} with #{args}..."

While we're at it, we can improve this a bit further by including the name of the class in the message as well.

It's possible that different classes will share the same method names; by including the class in the log message we avoid any possible confusion about which method was actually called.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args|
      puts "calling #{self.class}##{method_name} with #{args}..."
      super(*args)

Now we can just add back the call to super, but this time with the full set of arguments.

One last small thing: we should also pass any block through to super, in case the logged method takes a block. If we don't do this, our logger would just ignore any block passed to it, and break any methods that accept blocks.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args, &block|
      puts "calling #{self.class}##{method_name} with #{args}..."
      super(*args, &block)
    end
  end
end

And there we have it! Our new and improved logger builder is done. Let's try it again with the ApiResponse class.


ApiResponse.new(200, { foo: 'bar' }.to_json)
#=> calling ApiResponse#initialize with [200, {:foo=>"bar"}]...

And it works! We see the name of the class and method, and the value of all arguments passed into the method.


InternalApi.prepend(LoggerBuilder.new(:store_details))

What about that other class we had?


InternalApi.prepend(LoggerBuilder.new(:store_details))
InternalApi.new(...).store_details('details')
#=> calling InternalApi#store_details with ['details']

It works there too! In fact, this logger builder will work for pretty much any method on any class you can throw at it.

And that's really the magic of the module builder: it allows you to build modules according to a pattern. The pattern can be more general than hard-coded method names, as we've seen here. And that makes it very powerful.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args, &block|
      puts "calling #{self.class}##{method_name} with #{args}..."
      super(*args, &block)
    end
  end
end

Unfortunately, that also makes it a bit controversial. You can quickly get carried away.

For example, look again at this class. We're logging with puts. Suppose we wanted to make the builder accept a logger proc as an argument to the builder? That would make it more flexible, right? Could we do that?


class LoggerBuilder < Module
  def initialize(method_name, logger)
    define_method method_name do |*args, &block|
      logger.call("calling #{self.class}##{method_name} with #{args}...")
      super(*args, &block)
    end
  end
end

Well, sure we could: just add another argument to the initializer. The builder is more flexible now, right?

But wait, look at the code now. It's getting harder to understand what's going on. And things can get much more complex as you try to make a builder more generic.


class LoggerBuilder < Module
  def initialize(method_name)
    define_method method_name do |*args, &block|
      puts "calling #{self.class}##{method_name} with #{args}..."
      super(*args, &block)
    end
  end
end

So it's important to keep in mind that while module builders are very powerful, they also have their downsides.

Used with care, they can solve whole classes of problems that "normal" modules can't handle -- like the logging problem we saw in this series.

But they also add a level of indirection to code that has its own cost. When deciding whether to use a module builder, it's really important to consider this cost along with any potential benefits.

Ok, that's it for the second and last part of this series on module builders, hope you enjoyed it.

Until next time, happy hacking!

Responses