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.
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}"
api_key: sk-123
timeout: 60
debug: falseThis 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.
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}"
api_key: sk-123
timeout: 60This 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.
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
valid config: api_key=sk-123, timeout=60
caught: timeout must be positiveThe 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.
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
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.