Re-Organized configuration in Rails

A while back I wrote about organizing configuration in Rails. The idea was simple: drop YAML files into config/configurations/ and get namespaced constants like Config::Bot.api_key instead of the clunky Rails.application.config.bot.api_key.

It worked well. But every YAML file needed manual wiring: <%= ENV.fetch("BOT_API_KEY", Rails.application.credentials.dig(:bot, :api_key)) %>. For every key. Across every file. Ugh!

So I rebuilt it. Same clean Config::Namespace.key API, but now it chains through all three sources automatically.

Before (old module’s YAML):

# config/bot.yml
shared:
  api_key: <%= ENV.fetch("BOT_API_KEY", Rails.application.credentials.dig(:bot, :api_key)) %>
  user_agent: "MyAwesomeBot/1.0"
  timeout: 10

After (new module’s YAML):

# config/bot.yml
shared:
  # Config::Bot.api_key is still available and will check environment variables and then check credentials
  user_agent: "MyAwesomeBot/1.0"
  timeout: 10

One API. Three sources. No more guessing where a value lives.

You can find the full code on GitHub. What follows are the parts I find most interesting.

Lazy namespaces with const_missing

The old version scanned a directory at boot and called const_set for every YAML file. That works, but it means every namespace is loaded whether you use it or not.

This version uses const_missing instead. Reference Config::Bot for the first time and a Namespace object is created lazily:

def self.const_missing(name)
  MUTEX.synchronize do
    @namespaces ||= {}
    @namespaces[name] ||= Namespace.new(name)
  end
end

The Mutex isn’t there by accident. In threaded environments (Puma, Solid Queue), two threads could hit const_missing simultaneously. Mutex makes sure only one namespace object gets created.

The three source chain

Each Namespace uses method_missing to resolve a key:

def method_missing(method, ...)
  key = method.to_s.delete_suffix("!")
  bang = method.to_s.end_with?("!")

  environment_key = "#{@prefix}_#{key.upcase}"
  return @environment_cache[environment_key] if @environment_cache.key?(environment_key)

  value = Rails.application.credentials.dig(@credentials_key.to_sym, key.to_sym)
  return value unless value.nil?

  value = from_yaml(key)
  return value unless value.nil?

  raise(NotFoundError, "Config::#{@name}.#{key} not found") if bang

  nil
end

ENV is checked first. It caches a snapshot at boot via ENV.to_h, so there’s no hash lookup penalty on every access. Then credentials. Then the YAML file via Rails.application.config_for. Bang methods raise instead of returning nil.

The convention for ENV keys follows the namespace: Config::Bot.timeout checks ENV["BOT_TIMEOUT"]. Config::Stripe.api_key checks ENV["STRIPE_API_KEY"]. Predictable and consistent (change to your likings though).

Bang methods and discovery

I added two small quality of life features. Config::Bot.api_key! raises if nothing is found (useful in initializers where a missing secret should fail fast). And Config::Bot.keys lists everything available for that namespace, merging credential keys and YAML keys:

def keys
  credential_keys = Rails.application.credentials.dig(@credentials_key.to_sym)

  [credential_keys, yaml_config].compact.flat_map { it.keys }.uniq
end

There’s also a reload method that clears the ENV cache and YAML cache (for when you’re changing values in the console during development).

What about Rails 8.2’s creds?

Rails 8.2 (currently on main) is introducing Rails.app.creds, which provides Rails.app.creds.require(:stripe, :api_key) and Rails.app.creds.option(:stripe, :api_key). It checks ENV first, then encrypted credentials. Same layered idea, same bang and regular pattern.

The difference is scope. Rails.app.creds focuses on secrets (things that belong in encrypted credentials or ENV). My Config module adds YAML as a third tier, which is where I keep public configuration like URLs, timeouts, API versions and feature flags.

Both solve the same fundamental problem: stop guessing where a value lives and let a unified lookup handle it. Rails.app.creds is the Rails core answer (less setup, more opinionated). My Config module is the DIY answer (flexible, more tiers, yours to tweak).


I’ve been using this version of the Config module in production for some time now. The bang methods alone have caught two misconfigured deployments before they reached customers (the app refuses to boot if a required key is missing). The triple lookup means I can move a value from YAML to credentials to ENV without touching application code. Each tier handles what it’s best at.

The code is on GitHub if you want to drop it into your own app or adapt it.

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?