DEV Community

Vinicius Stock
Vinicius Stock

Posted on • Edited on

Make a Ruby gem configurable

In the Ruby ecosystem, a well established way of customizing gem behavior is by using configuration blocks. Most likely, you came across code that looked like this.

MyGem.configure do |config|
  config.some_config = true
  config.some_class = MyApp
  config.some_lambda = ->(variable) { do_important_stuff(variable) }
end
Enter fullscreen mode Exit fullscreen mode

This standard way of configuring gems runs once, usually when starting an application, and allows developers to tailor the gem's functionality to their needs.

Adding the configuration block to a gem

Let's take a look at how this style of configuration can be implemented. We'll divide our implementation in two steps: writing a configuration class and exposing it to users.

Writing the configuration class

The configuration class is responsible for keeping all the available options and their defaults. It is composed of two things: an initialize method where defaults are set and attribute readers/writers.

Let's take a look at an example, where we can configure an animal and the sound that it makes. If the sound does not match the expected, we want to raise an error while configuring our gem. We'll then access what is configured for animal and sound and use it in our logic.

# lib/my_gem/configuration.rb

module MyGem
  class Configuration
    # Custom error class for sounds that don't
    # match the expected animal
    class AnimalSoundMismatch < StandardError; end

    # Animal sound map
    ANIMAL_SOUND_MAP = {
      "Dog" => "Barks",
      "Cat" => "Meows"
    }

    # Writer + reader for the animal instance variable. No fancy logic
    attr_accessor :animal

    # Reader only for the sound instance variable.
    # The writer contains custom logic
    attr_reader :sound

    # Initialize every configuration with a default.
    # Users of the gem will override these with their
    # desired values
    def initialize
      @animal = "Dog"
      @sound = "Barks"
    end

    # Custom writer for sound.
    # If the sound variable is not exactly what is
    # mapped in our hash, raise the custom error
    def sound=(sound)
      raise AnimalSoundMismatch, "A #{@animal} can't #{sound}" if SOUND_MAP[@animal] != sound

      @sound = sound
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Exposing the configuration

To expose the configuration means both letting users configure it and allowing the gem itself to read the value of each option.

This is done with a singleton of our Configuration class and a few utility methods.

# lib/my_gem.rb

module MyGem
  class << self
    # Instantiate the Configuration singleton
    # or return it. Remember that the instance
    # has attribute readers so that we can access
    # the configured values
    def configuration
      @configuration ||= Configuration.new
    end

    # This is the configure block definition.
    # The configuration method will return the
    # Configuration singleton, which is then yielded
    # to the configure block. Then it's just a matter
    # of using the attribute accessors we previously defined
    def configure
      yield(configuration)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The gem is now set to be configured by the applications that use it.

MyGem.configure do |config|
  # Notice that the config block argument
  # is the yielded singleton of Configuration.
  # In essence, all we're doing is using the
  # accessors we defined in the Configuration class
  config.animal = "Cat"
  config.sound = "Meows"
end
Enter fullscreen mode Exit fullscreen mode

Using the configuration in the gem

Now that we have the customizable Configuration singleton, we can read the values to change behavior based on it.

# lib/my_gem/make_sound.rb

module MyGem
  class AnimalSound
    def initialize
      @animal = MyGem.configuration.animal
      @sound = MyGem.configuration.sound
    end

    def make_sound
      "The #{@animal} #{@sound}"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Using lambdas for configuration

In specific scenarios, there may be a need for a configuration to not have a predetermined value, but rather to evaluate some logic as the application is running.

For these cases, it is typical to define a lambda for the configuration value. Let's go through an example. The configuration class is similar to our previous case.

# lib/my_gem/configuration.rb

module MyGem
  class Configuration
    attr_reader :key_name

    # Define no lambda as the default
    def initialize
      @key_name = nil
    end

    # Raise an error if trying to set the key_name
    # to something other than a lambda
    def key_name=(lambda)
      raise ArgumentError, "The key_name must be a lambda" unless lambda.is_a?(Proc)

      @key_name = lambda
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we can configure the lambda to whatever we need. You could even query the database if desired inside the lambda and return values from the app's models.

MyGem.configure do |config|
  config.lambda_config = ->(model_name) { model_name == "Post" ? :posts : :articles }
end
Enter fullscreen mode Exit fullscreen mode

Finally, the gem can use the lambda and get different results as the app is running.

MyGem.configuration.key_name&.call("Article")
=> :articles

MyGem.configuration.key_name&.call("Post")
=> :posts
Enter fullscreen mode Exit fullscreen mode

That's about it for configuration blocks. Have you used this before to make your code/gems customizable? Do you know other strategies for configuring third party libraries? Let me know in the comments!

Top comments (7)

Collapse
 
mereghost profile image
Marcello Rocha

Unless I'm trying to avoid dependencies, my usual go to for configuration is dry-configurable as it provides a nice way to to define, even deeply nested, settings.

As a bonus you get settings thread safety.

Collapse
 
vinistock profile image
Vinicius Stock

Oh, nice! I had not heard about that one, will have to check it out. And yeah, the solution in this article is not thread safe.

Collapse
 
mereghost profile image
Marcello Rocha

Dry-rb has a lot of nice gems with basically no heavy dependencies.

Totally worth a look. Maybe worth an internal presentation @ Shopify, fellow Shopifolk.

Collapse
 
wulymammoth profile image
David • Edited

Love that you wrote about this — I see a lot of code that has instance variables that are really configuration vars. I, too, have been guilty of this, whenever I touch code and find myself altering one, I see if it’s too much effort. Most of the time things were simple enough that just needed a hash of key-values needing only a setter and getter. What I end up using is ActiveSupport Configurable. Are you familiar with it and do you use or advise the use of it?

Collapse
 
vinistock profile image
Vinicius Stock

I know it exists, but haven't used it myself. Do you find it more convenient? The only drawback in my opinion would be adding activesupport as a dependency if all you need is the configurable part.

Collapse
 
wulymammoth profile image
David • Edited

I do! I've read about the different ways to do configurations, several blog posts from different people that I admire. I can't seem to find it in my bookmarks right now, but it's actually how I discovered it... the below is literally pulled from the Rails docs. But yeah, I've done none of the other variants, and found yours very interesting.

require 'active_support/configurable'

class User
  include ActiveSupport::Configurable
end

user = User.new

user.config.allowed_access = true
user.config.level = 1

user.config.allowed_access # => true
user.config.level          # => 1

docs

Collapse
 
vinistock profile image
Vinicius Stock • Edited

Thanks for the heads up! Fixed the lambda syntax. I like the DSL approach too. The only inconvenience is that editors usually won't autocomplete the options for you, but with proper docs that's easy to overcome.

I actually wrote a bit on writing DSLs with instance_eval :)