Gem configuration patterns - Josh Brody Gem configuration patterns | Josh Brody
Back

Gem configuration patterns

Configuration is often the first thing users interact with in your gem. It’s also the part most gem authors spend the least time thinking about—which explains a lot. This post covers the patterns you’ll encounter and implement, ordered from the simplest to the most involved.

Module accessors

The simplest possible approach. You expose a few attributes on your gem’s main module and call it a day.

123456789101112131415
module MyGem
  class << self
    attr_accessor :api_key, :timeout, :debug
  end

  self.timeout = 30
  self.debug = false
end

MyGem.api_key = "sk-123"
MyGem.timeout = 60

puts "api_key: #{MyGem.api_key}"
puts "timeout: #{MyGem.timeout}"
puts "debug: #{MyGem.debug}"
loads ~35MB Ruby environment
api_key: sk-123
timeout: 60
debug: false

This works for gems with one or two options. It falls apart quickly once you need defaults that depend on each other, validation, or any structure. Most gems outgrow this within a few releases—usually right around the time someone opens an issue asking why their configuration disappeared between requests.

Block-based configuration

The standard pattern. Users call a configure method with a block, and you yield a configuration object.

12345678910111213141516171819202122232425262728
module MyGem
  class Configuration
    attr_accessor :api_key, :timeout, :debug

    def initialize
      @timeout = 30
      @debug = false
    end
  end

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

    def configure
      yield(configuration)
    end
  end
end

MyGem.configure do |config|
  config.api_key = "sk-123"
  config.timeout = 60
end

puts "api_key: #{MyGem.configuration.api_key}"
puts "timeout: #{MyGem.configuration.timeout}"
loads ~35MB Ruby environment
api_key: sk-123
timeout: 60

This is what most gems use, and for good reason. It scales reasonably well, keeps defaults in one place, and the syntax is familiar enough that users can configure your gem without reading the docs. Whether they will read the docs is a separate problem.

Configuration with validation

Once you have a configuration class, you can add validation. Catch mistakes early rather than letting them surface as cryptic errors deep in your code.

123456789101112131415161718192021222324252627282930313233343536
module MyGem
  class Configuration
    attr_reader :api_key, :timeout, :retry_count

    def initialize
      @timeout = 30
      @retry_count = 3
    end

    def api_key=(value)
      raise ArgumentError, "api_key cannot be blank" if value.nil? || value.empty?
      @api_key = value
    end

    def timeout=(value)
      raise ArgumentError, "timeout must be positive" unless value.is_a?(Numeric) && value > 0
      @timeout = value
    end

    def retry_count=(value)
      raise ArgumentError, "retry_count must be between 0 and 10" unless (0..10).cover?(value)
      @retry_count = value
    end
  end
end

config = MyGem::Configuration.new
config.api_key = "sk-123"
config.timeout = 60
puts "valid config: api_key=#{config.api_key}, timeout=#{config.timeout}"

begin
  config.timeout = -5
rescue ArgumentError => e
  puts "caught: #{e.message}"
end
loads ~35MB Ruby environment
valid config: api_key=sk-123, timeout=60
caught: timeout must be positive

The error messages should be specific. “Invalid timeout” tells users nothing; “timeout must be positive” tells them exactly what went wrong. Your future self debugging a production issue at 2am will thank you.

Nested configuration

When your gem has distinct subsystems, nested configuration keeps things organized. Users access settings via config.api.timeout rather than config.api_timeout.

module MyGem
  class Configuration
    attr_reader :api, :cache, :logging

    def initialize
      @api = ApiConfig.new
      @cache = CacheConfig.new
      @logging = LoggingConfig.new
    end
  end

  class ApiConfig
    attr_accessor :endpoint, :timeout, :retries

    def initialize
      @endpoint = "https://api.example.com"
      @timeout = 30
      @retries = 3
    end
  end

  class CacheConfig
    attr_accessor :enabled, :ttl, :store

    def initialize
      @enabled = true
      @ttl = 3600
      @store = :memory
    end
  end

  class LoggingConfig
    attr_accessor :level, :logger

    def initialize
      @level = :info
      @logger = nil
    end
  end
end

Users configure it like this:

MyGem.configure do |config|
  config.api.timeout = 60
  config.api.retries = 5
  config.cache.enabled = false
  config.logging.level = :debug
end

This pattern makes sense when you have clear boundaries between concerns. If your gem only has five or six settings total, nesting just gives users more dots to type for no reason.

Per-instance vs global configuration

Some gems need both global defaults and per-instance overrides. A Stripe-style client that can operate with different API keys is the canonical example.

module MyGem
  class << self
    def configuration
      @configuration ||= Configuration.new
    end

    def configure
      yield(configuration)
    end

    # convenience method using global config
    def client
      @client ||= Client.new(configuration)
    end
  end

  class Configuration
    attr_accessor :api_key, :timeout

    def initialize(api_key: nil, timeout: 30)
      @api_key = api_key
      @timeout = timeout
    end

    def dup
      Configuration.new(api_key: api_key, timeout: timeout)
    end
  end

  class Client
    attr_reader :config

    def initialize(config = nil)
      @config = config || MyGem.configuration.dup
    end

    def request(path)
      # uses @config.api_key, @config.timeout
    end
  end
end

Users can use the global client or create their own:

# global configuration
MyGem.configure do |config|
  config.api_key = "sk-default"
end

# use global client
MyGem.client.request("/users")

# per-instance client with different credentials
other_client = MyGem::Client.new(
  MyGem::Configuration.new(api_key: "sk-other")
)
other_client.request("/users")

The dup method matters here. Without it, per-instance clients that start from the global config would share the same configuration object, and changes to one would affect all of them. I have debugged this exact issue more times than I care to admit.

Rails integration

If your gem integrates with Rails, you probably want an initializer generator and possibly a Railtie.

The generator creates a config file:

# lib/generators/my_gem/install_generator.rb
module MyGem
  module Generators
    class InstallGenerator < Rails::Generators::Base
      source_root File.expand_path("templates", __dir__)

      def copy_initializer
        template "initializer.rb", "config/initializers/my_gem.rb"
      end
    end
  end
end
# lib/generators/my_gem/templates/initializer.rb
MyGem.configure do |config|
  config.api_key = ENV["MY_GEM_API_KEY"]
  config.timeout = 30
end

Users run rails generate my_gem:install and get a starting point. They also get the satisfaction of feeling like they configured something, even if they just accepted all the defaults.

A Railtie lets you hook into Rails’ lifecycle:

# lib/my_gem/railtie.rb
module MyGem
  class Railtie < Rails::Railtie
    initializer "my_gem.configure" do
      MyGem.configuration.logger ||= Rails.logger
    end

    config.after_initialize do
      if MyGem.configuration.api_key.nil?
        Rails.logger.warn "[MyGem] No API key configured"
      end
    end
  end
end

The Railtie is autoloaded when Rails loads your gem—as long as you require it in your main gem file when Rails is present:

# lib/my_gem.rb
require "my_gem/railtie" if defined?(Rails::Railtie)

DSL-based configuration

When block-based configuration isn’t expressive enough, a DSL can help. This is common in gems that configure complex structures like routes, state machines, or pipelines.

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
module MyGem
  class Configuration
    attr_reader :endpoints

    def initialize
      @endpoints = {}
    end

    def endpoint(name, &block)
      ep = EndpointConfig.new(name)
      ep.instance_eval(&block)
      @endpoints[name] = ep
    end
  end

  class EndpointConfig
    attr_reader :name, :headers

    def initialize(name)
      @name = name
      @http_method = :get
      @headers = {}
    end

    def path(value = nil)
      value ? @path = value : @path
    end

    def http_method(value = nil)
      value ? @http_method = value : @http_method
    end

    def header(key, value)
      @headers[key] = value
    end
  end
end

config = MyGem::Configuration.new

config.endpoint :users do
  path "/api/v1/users"
  http_method :get
  header "Accept", "application/json"
end

config.endpoint :create_user do
  path "/api/v1/users"
  http_method :post
  header "Content-Type", "application/json"
end

config.endpoints.each do |name, ep|
  puts "#{name}: #{ep.http_method.upcase} #{ep.path}"
  puts "  headers: #{ep.headers.inspect}"
end
loads ~35MB Ruby environment
users: GET /api/v1/users
  headers: {"Accept" => "application/json"}
create_user: POST /api/v1/users
  headers: {"Content-Type" => "application/json"}

DSLs feel elegant when they fit the domain. They also add cognitive load—users have to learn your DSL’s syntax and quirks, and you have to maintain the DSL code, which is never as simple as it looked when you started. Use this when the configuration genuinely benefits from the expressiveness; don’t use it just because you saw it in a conference talk.

Multi-source configuration

Production applications often configure gems from multiple sources: environment variables, YAML files, and code. Handling this gracefully means establishing clear precedence rules.

module MyGem
  class Configuration
    attr_accessor :api_key, :timeout, :environment

    def initialize
      @timeout = 30
      @environment = :production
      load_from_env
    end

    def load_from_env
      @api_key = ENV["MY_GEM_API_KEY"] if ENV["MY_GEM_API_KEY"]
      @timeout = ENV["MY_GEM_TIMEOUT"].to_i if ENV["MY_GEM_TIMEOUT"]
      @environment = ENV["MY_GEM_ENV"]&.to_sym if ENV["MY_GEM_ENV"]
    end

    def load_from_yaml(path)
      return unless File.exist?(path)

      yaml = YAML.load_file(path, permitted_classes: [Symbol])
      env_config = yaml[@environment.to_s] || yaml["default"] || {}

      @api_key ||= env_config["api_key"]
      @timeout = env_config["timeout"] if env_config["timeout"]
    end
  end

  class << self
    def configure
      yield(configuration)
      configuration.load_from_yaml("config/my_gem.yml") if defined?(Rails)
    end
  end
end

The precedence here is: code overrides YAML overrides ENV overrides defaults. You could argue for different orderings—some gems prefer ENV to override everything for 12-factor compliance. Pick one and document it clearly. Users will still be confused, but at least you’ll have something to point them to.

A YAML file might look like this:

# config/my_gem.yml
default:
  timeout: 30

development:
  api_key: "sk-dev"
  timeout: 60

production:
  timeout: 15

Credentials in YAML files are a security concern. For anything sensitive, ENV variables or Rails credentials are safer choices. If you do support YAML credentials, at least check that the file permissions aren’t world-readable.

Choosing a pattern

Start with the simplest pattern that meets your needs:

  • Module accessors for gems with one or two settings;
  • block-based configuration for most everything else;
  • validation when misconfiguration causes confusing errors downstream;
  • nested configuration when you have distinct subsystems that warrant their own namespace;
  • per-instance configuration when users need multiple clients with different credentials;
  • Rails integration when your gem is Rails-focused and you want to seem professional;
  • DSLs when block-based genuinely isn’t expressive enough;
  • multi-source when your users deploy to environments where ENV-based configuration is expected

Most gems do fine with block-based configuration and validation. The fancier patterns exist for gems that genuinely need them—not as a badge of sophistication.

Stay in the loop

Occasional essays on design, tools, and the craft of building things. No spam, unsubscribe anytime.

Ambient weather

The background of this site reflects the current weather and time of day in Saint Paul. The orbs shift in color and behavior based on what's happening outside my window.

Learn more about how this works