Ruby Gem Configuration Patterns

This is one of the final post leading up the the launch of the Build a Ruby Gem Ebook, which is now available for sale in 3 packages, including 14 chapters of code and over 2 hours of screencasts.

The world isn’t black and white (as much as we’d like to believe it is). Just because our gem’s functionality may work for us, doesn’t mean it’ll work for everyone.

Fortunately, we can give users the ability to add custom configuration data, allowing them to adapt our code to their own use. In this post, we’ll adapt the mega_lotto gem to take a configuration block that modifies the number of integers returned from the #draw method output.

Use Case

Our mega_lotto gem provides the functionality to randomly draw 6 numbers. However, let’s assume that someone else has taken interest in the code for our gem, but needs the code to generate 10 random numbers within the same range.

One option would be for them to fork the code and modify the gem to fit their needs. However, now there’s a randomly forked gem with the same name and it’s unclear why one should be used over the other, especially if no changes to the README were made.

Rather than go down that path, we can make our existing mega_lotto gem more flexible by returning 6 integers by default, while also providing an interface to customize this value for those with the need.

Our goal is to adapt our gem to take a configuration block like this:

MegaLotto.configure do |config|
  config.drawing_count = 10
end

Implementation

Let’s first write some specs for the desired functionality. Because the .configure method above is off the main MegaLotto namespace, we’ll create the spec file spec/mega_lotto_spec.rb. With this spec, we’ll assert that after running the configuration block above, the #drawing method returns an array (like before), but this time with 10 integers instead:

require "spec_helper"

describe MegaLotto do
  describe "#configure" do
    before do
      MegaLotto.configure do |config|
        config.drawing_count = 10
      end
    end

    it "returns an array with 10 elements" do
      draw = MegaLotto::Drawing.new.draw

      expect(draw).to be_a(Array)
      expect(draw.size).to eq(10)
    end
  end
end

This spec serves as higher level integration spec because it’s accessing the public API of our gem. Because of this, we can be sure that once this spec passes, our implementation is complete. As expected, when we run this spec, it fails:

MegaLotto
  #configure
    returns an array with 10 elements (FAILED - 1)

Failures:

  1) MegaLotto#config returns an array with 10 elements
     Failure/Error: MegaLotto.configure do |config|
     NoMethodError:
       undefined method `configure` for MegaLotto:Module
     # ./spec/mega_lotto_spec.rb:6

Finished in 0.00131 seconds
1 example, 1 failure

Now that we have a spec to work against, let’s continue our implementation.

The failure above complained that there was no MegaLotto.configure method, so let’s add it:

module MegaLotto
  def self.configure
  end
end

Re-running our specs gives us a different failure message this time:

MegaLotto
  #configure
    returns an array with 10 elements (FAILED - 1)

Failures:

  1) MegaLotto#configure returns an array with 10 elements
     Failure/Error: expect(draw.size).to eq(10)

       expected: 10
            got: 6

       (compared using ==)
     # ./spec/mega_lotto_spec.rb:15

Finished in 0.00246 seconds
1 example, 1 failure

The output now shows that the code still returned 6 integers, which we expected because our .configure method hasn’t done anything yet.

Because we’re using a object-oriented language like Ruby, we can create a Configuration class whose responsibility will be…(drumroll)….configuration!

Let’s start with some specs:

# spec/mega_lotto/configuration_spec.rb

require "spec_helper"

module MegaLotto
  describe Configuration do
    describe "#drawing_count" do
      it "default value is 6" do
        Configuration.new.drawing_count = 6
      end
    end

    describe "#drawing_count=" do
      it "can set value" do
        config = Configuration.new
        config.drawing_count = 7
        expect(config.drawing_count).to eq(7)
      end
    end
  end
end

Running the configuration specs produces:

/Users/bhilkert/Dropbox/code/mega_lotto/spec/
mega_lotto/configuration_spec.rb:4:in `<module:MegaLotto>`:
uninitialized constant MegaLotto::Configuration (NameError)

Let’s add the Configuration class:

# lib/mega_lotto/configuration.rb

module MegaLotto
  class Configuration
  end
end

Let’s try again:

/Users/bhilkert/Dropbox/code/mega_lotto/spec/
mega_lotto/configuration_spec.rb:4:in `<module:MegaLotto>`:
uninitialized constant MegaLotto::Configuration (NameError)

What??? Same message…Even though we added the Configuration class above, our gem doesn’t load it. So we can dive in to the entry file lib/mega_lotto.rb and add the appropriate require statement:

require "mega_lotto/version"
require "mega_lotto/configuration"
require "mega_lotto/drawing"

begin
  require "pry"
rescue LoadError
end

module MegaLotto
  def self.configure
  end
end

Now with the Configuration class properly loaded, let’s run our specs again:

MegaLotto::Configuration
  #drawing_count
    default value is 6 (FAILED - 1)
  #drawing_count=
    can set value (FAILED - 2)

Failures:

  1) MegaLotto::Configuration#drawing_count default value is 6
     Failure/Error: expect(config.drawing_count).to eq(6)
     NoMethodError:
       undefined method `drawing_count` for #<MegaLotto::Configuration>
     # ./spec/mega_lotto/configuration_spec.rb:8

  2) MegaLotto::Configuration#drawing_count= can set value
     Failure/Error: config.drawing_count = 7
     NoMethodError:
       undefined method `drawing_count=` for #<MegaLotto::Configuration>
     # ./spec/mega_lotto/configuration_spec.rb:15

Finished in 0.00175 seconds
2 examples, 2 failures

Even though we still have failures, we’re making progress. The failures above relate to the lack of a #drawing_count= method, so let’s add an accessor for it:

module MegaLotto
  class Configuration
    attr_accessor :drawing_count
  end
end

Note: We could’ve just added an attr_writer to satisfy the spec. However, I know I’m going to need a getter down the road, so I chose to do it at the same time.

With our accessor in place, let’s check the specs again:

MegaLotto::Configuration
  #drawing_count=
    can set value
  #drawing_count
    default value is 6 (FAILED - 1)

Failures:

  1) MegaLotto::Configuration#drawing_count default value is 6
     Failure/Error: expect(config.drawing_count).to eq(6)

       expected: 6
            got: nil

       (compared using ==)
     # ./spec/mega_lotto/configuration_spec.rb:8

Finished in 0.00239 seconds
2 examples, 1 failure

Still a failure, but we’re slowly making more progress. The default value isn’t getting set so we’ll change that in the implementation:

module MegaLotto
  class Configuration
    attr_accessor :drawing_count

    def initialize
      @drawing_count = 6
    end
  end
end

Running the specs one more time for the Configuration class shows that we’re good:

MegaLotto::Configuration
  #drawing_count
    default value is 6
  #drawing_count=
    can set value

Finished in 0.00172 seconds
2 examples, 0 failures

Running the specs for the main spec/mega_lotto.rb class again:

MegaLotto
  #configure
    returns an array with 10 elements (FAILED - 1)

Failures:

  1) MegaLotto#configure returns an array with 10 elements
     Failure/Error: expect(draw.size).to eq(10)

       expected: 10
            got: 6

       (compared using ==)
     # ./spec/mega_lotto_spec.rb:15:in `block (3 levels) in <top (required)>'

Finished in 0.00168 seconds
1 example, 1 failure

We still have the same failures from before, but it’s because we didn’t change the MegaLotto::Drawing to actually use the new configuration class. Since we have this awesome new class, let’s make use of it in MegaLotto::Drawing:

module MegaLotto
  class Drawing
    def draw
      MegaLotto.configuration.drawing_count.times.map { single_draw }
    end

    private

    def single_draw
      rand(0...60)
    end
  end
end

Running the specs for the drawing class gives us the following output:

MegaLotto::Drawing
  #draw
    each element is less than 60 (FAILED - 1)
    each element is an integer (FAILED - 2)
    returns an array (FAILED - 3)
    using the default drawing count
      returns an array with 6 elements (FAILED - 4)

Failures:

  1) MegaLotto::Drawing#draw each element is less than 60
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `configuration` for MegaLotto:Module
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:19

  2) MegaLotto::Drawing#draw each element is an integer
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `configuration` for MegaLotto:Module
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:13

  3) MegaLotto::Drawing#draw returns an array
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `configuration` for MegaLotto:Module
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:9

  4) MegaLotto::Drawing#draw using the default
      drawing count returns an array with 6 elements
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `configuration` for MegaLotto:Module
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:26

Finished in 0.00219 seconds
4 examples, 4 failures

Well…I guess it’s clear that it doesn’t have a configuration accessor, huh? Let’s add it to lib/mega_lotto.rb:

module MegaLotto
  class << self
    attr_accessor :configuration
  end

  def self.configure
  end
end

and our specs:

MegaLotto::Drawing
  #draw
    each element is less than 60 (FAILED - 1)
    each element is an integer (FAILED - 2)
    returns an array (FAILED - 3)
    using the default drawing count
      returns an array with 6 elements (FAILED - 4)

Failures:

  1) MegaLotto::Drawing#draw each element is less than 60
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `drawing_count` for nil:NilClass
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:19

  2) MegaLotto::Drawing#draw each element is an integer
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `drawing_count` for nil:NilClass
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:13

  3) MegaLotto::Drawing#draw returns an array
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `drawing_count` for nil:NilClass
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:9

  4) MegaLotto::Drawing#draw using the default
      drawing count returns an array with 6 elements
     Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
     NoMethodError:
       undefined method `drawing_count` for nil:NilClass
     # ./lib/mega_lotto/drawing.rb:4:in `draw'
     # ./spec/mega_lotto/drawing_spec.rb:6
     # ./spec/mega_lotto/drawing_spec.rb:26

Finished in 0.00146 seconds
4 examples, 4 failures

A different message this time, related to the fact that the configuration accessor has no #drawing_count method. This makes sense because we don’t actually return anything from #configuration. Let’s instantiate a new Configuration object and see where that gets us:

module MegaLotto
  class << self
    attr_writer :configuration
  end

  def self.configuration
    Configuration.new
  end

  def self.configure
  end
end

Now, the Drawing class specs are passing:

MegaLotto::Drawing
  #draw
    each element is an integer
    each element is less than 60
    returns an array
    using the default drawing count
      returns an array with 6 elements

Finished in 0.01007 seconds
4 examples, 0 failures

Let’s flip back to the spec file spec/mega_lotto_spec.rb and see where we are:

MegaLotto
  #configure
    returns an array with 10 elements (FAILED - 1)

Failures:

  1) MegaLotto#configure returns an array with 10 elements
     Failure/Error: expect(draw.size).to eq(10)

       expected: 10
            got: 6

       (compared using ==)
     # ./spec/mega_lotto_spec.rb:15

Finished in 0.00167 seconds
1 example, 1 failure

Still failing, but at least we have what seems like the pieces setup to implement the global configuration. The .configure methods needs to yield the configuration block to a new instance of the Configuration class. However, we’ll need to memoize the configuration instance, so when the Drawing class accesses #drawing_count, it returns the initial configuration value:

module MegaLotto
  class << self
    attr_writer :configuration
  end

  def self.configuration
    @configuration ||= Configuration.new
  end

  def self.configure
    yield(configuration)
  end
end

Note: it’s important to return the class instance variable from .configuration and check if that is set rather than the reader method, otherwise it’d get stuck in a loop and never return.

Running our specs again, we see that we’re green:

MegaLotto
  #configure
    returns an array with 10 elements

Finished in 0.00168 seconds
1 example, 0 failures

For the sake of sanity, let’s run the whole suite to make sure everything is covered:

$ rake
.......

Finished in 0.00688 seconds
7 examples, 0 failures

…and we’re good! Except, if we run our entire suite a few times in a row, we’ll eventually see a failure:

Failures:

  1) MegaLotto::Drawing#draw returns an Array with 6 elements
     Failure/Error: expect(drawing.size).to eq(6)

       expected: 6
            got: 10

       (compared using ==)
     # ./spec/mega_lotto/drawing_spec.rb:13

Finished in 0.00893 seconds
7 examples, 1 failure

What’s going on???

In the setup of the spec for MegaLotto.configure, we added the following before block:

before :each do
  MegaLotto.configure do |config|
    config.drawing_count = 10
  end
end

Because this configuration is global, if this spec is run before the others in our suite, the remaining specs will use it. So when the specs for MegaLotto::Drawing run, 10 elements are return instead of the 6, the default, and we see the failure.

For global values like this, it’s best to clean up after each spec to ensure the system is back to a default state. In our case, we can implement a .reset method on MegaLotto and set the configuration back to a new instance of the Configuration class. Let’s start with a spec for this in spec/mega_lotto_spec.rb:

  describe ".reset" do
    before :each do
      MegaLotto.configure do |config|
        config.drawing_count = 10
      end
    end

    it "resets the configuration" do
      MegaLotto.reset

      config = MegaLotto.configuration

      expect(config.drawing_count).to eq(6)
    end
  end

As expected, we see failure because we have yet to implement the .reset method:

Failures:

  1) MegaLotto.reset resets the configuration
     Failure/Error: MegaLotto.reset
     NoMethodError:
       undefined method `reset` for MegaLotto:Module
     # ./spec/mega_lotto_spec.rb:28

Finished in 0.00762 seconds
8 examples, 1 failure

Let’s do that now:

module MegaLotto
  class << self
    attr_writer :configuration
  end

  def self.configuration
    @configuration ||= Configuration.new
  end

  def self.reset
    @configuration = Configuration.new
  end

  def self.configure
    yield(configuration)
  end
end

Our specs for the .reset method pass, so now we need to make use of it to clean up after our .configure spec:

describe "#configure" do

  before :each do
    MegaLotto.configure do |config|
      config.drawing_count = 10
    end
  end

  it "returns an array with 10 elements" do
    draw = MegaLotto::Drawing.new.draw

    expect(draw).to be_a(Array)
    expect(draw.size).to eq(10)
  end

  after :each do
    MegaLotto.reset
  end
end

Now we can be sure that our specs pass no matter the order of execution.

Local Configuration

The configuration approach above implements a global configuration object. The downside is that we can’t have multiple instances of our code running with different configurations. To avoid this, we could isolate the configuration class and only pass it to those objects that need it. By doing this, we’d avoid needing the MegaLotto.configure method entirely.

With this in mind, Drawing might look like:

module MegaLotto
  class Drawing
    attr_accessor :config

    def initialize(config = Configuration.new)
      @config = config
    end

    def draw
      config.drawing_count.times.map { single_draw }
    end

    private

    def single_draw
      rand(0...60)
    end
  end
end

We can supply our own configuration object during instantiation if the defaults aren’t appropriate. In this case, as long as the object responds to drawing_count, everything will work.

require 'ostruct'
config = OpenStruct.new(drawing_count: 10)

MegaLotto::Drawing.new(config).draw #=> [23, 4, 21, 33, 48, 12, 43, 13, 2, 5]

Both approaches are certainly valid, so I’ll leave it to you to decide which approach is best for your gem.

Implementations in the Wild

The CarrierWave gem is a popular choice to support avatar uploading. The author(s) realized that not everyone would want to store upload assets on the local system, so they offered the functionality to support Amazon S3 and other similar block storage services. In order to set this value, you’d use a configure block almost identical to the one we wrote above.

Thoughtbot wrote a great article about the configuration implementation in their Clearance gem. It’s worth reading even if you don’t plan to use Clearance.

Summary

Keeping gems configurable means balancing your use case with the use cases of others. The more flexibility you offer to users of your gem, the more users will find value in your work. However, there’s a point when offering too much configuration can make the internals of a gem unnecessarily complicated. As you probably know, Ruby is a language full of conventions and it’s best to provide reasonable defaults and only adapt if the need arises.

One approach to balance complexity is to create a system where users can write their own middleware to modify the behavior of the default system. Mike Perham created a middleware system for Sidekiq allowing users to add functionality as they wish. Doing so doesn’t require the gem to change at all when unique use cases arise. The implementation of such system is beyond the scope of this book. However, if you want to learn more, Sidekiq’s implementation is a great place to start.

Comments

Ruby/Rails Newsletter - FREE!

Join 2,500+ other Rubyists and take your skills to the next level!

Ruby and Rails tips delivered to your inbox 2-4 times per month. No obligation and absolutely no spam. Unsubscribe at any time.

Enter your email below to get started today: