Back to Course

DIP Series · 2 of 3

Configuration as an Injected Dependency

Where Rails' configuration pattern comes from, why ENV.fetch scattered through your code is a coupling problem, and how Rails.application.config.x makes configuration a real, injectable dependency.

Where this rule comes from

The first DIP lesson focused on injecting collaborators: notifiers, clients, services. This lesson is about a quieter form of dependency that almost every Rails app gets wrong at some point: configuration. The webhook URL the Slack notifier needs. The API key for Stripe. The bucket name for uploads. The number of seconds before a token expires. These are not collaborators; they are values. But they are still dependencies, and they still want to be injected.

The naive Rails app reaches for ENV.fetch("X") wherever it needs a config value, scattered across services, models, jobs, and initializers. This works on day one. It rots over time. Tests have to set environment variables; deploys have to remember every variable in use; refactors have to find every ENV.fetch call.

Rails ships a better pattern, hiding in plain sight: Rails.application.config.x. The .x namespace is a Rails-blessed home for application-specific configuration. You set values once, in an initializer or environment-specific config file. Every class that needs them reads from the same place. Configuration becomes a single, central, injectable dependency instead of a swarm of ENV.fetch calls.

The senior framing: your configuration is a dependency, like any other. The DIP move is to give it a single, named home, not to look it up from inside the code that uses it.

The anti-pattern

Picture a Rails app where the team has been adding integrations for two years. Stripe, Mailchimp, Slack, Twilio, Sentry, Datadog. Every integration's configuration lives wherever the integration's first caller happened to be:

# app/services/charge_user.rb
class ChargeUser
  def call(user, cents)
    Stripe.api_key = ENV.fetch("STRIPE_SECRET_KEY")
    Stripe::Charge.create(
      amount:   cents,
      currency: ENV.fetch("STRIPE_CURRENCY", "usd"),
      customer: user.stripe_customer_id
    )
  end
end

# app/jobs/send_newsletter_job.rb
class SendNewsletterJob
  def perform(list_id)
    client = Mailchimp::API.new(ENV.fetch("MAILCHIMP_API_KEY"))
    client.lists.members(list_id).each { |m| send_to(m.email) }
  end
end

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  def stripe
    payload   = request.body.read
    signature = request.headers["Stripe-Signature"]
    event     = Stripe::Webhook.construct_event(
      payload, signature, ENV.fetch("STRIPE_WEBHOOK_SECRET")
    )
    # ...
  end
end

# config/initializers/sentry.rb
Sentry.init do |config|
  config.dsn = ENV.fetch("SENTRY_DSN", nil)
  config.traces_sample_rate = ENV.fetch("SENTRY_SAMPLE_RATE", "0.1").to_f
end

Four files, four different patterns, two duplicate Stripe ENV reads. What goes wrong:

  • There is no single place that lists what configuration the app needs. Onboarding a new developer means grepping the codebase for ENV.fetch calls and hoping you found them all.
  • Tests have to set environment variables, even for fully isolated unit tests. A test that exercises ChargeUser has to ensure STRIPE_SECRET_KEY is set in the test environment, or the production lookup blows up.
  • Defaults are inconsistent. Some lookups use fetch with a default; some raise on missing values; some default to nil. The behavior of "what happens if STRIPE_CURRENCY is unset?" depends on which file you look at.
  • Type coercion happens at every call site. ENV.fetch("SAMPLE_RATE", "0.1").to_f repeats every time the value is read. Forgetting .to_f in one place is a subtle production bug.
  • The dependency is invisible. Reading ChargeUser tells you it depends on Stripe. It does not tell you it depends on which Stripe configuration values, until you read the body of every method.

The Rails-blessed pattern: config.x

Rails 4.2 added a configuration namespace called Rails.application.config.x, intended exactly for this use case. Every config value the app needs gets defined in one place — usually config/application.rb or an environment file — and every caller reads from the same namespace. The shape:

# config/application.rb
module MyApp
  class Application < Rails::Application
    # ... Rails defaults ...

    config.x.stripe.secret_key      = ENV.fetch("STRIPE_SECRET_KEY", nil)
    config.x.stripe.publishable_key = ENV.fetch("STRIPE_PUBLISHABLE_KEY", nil)
    config.x.stripe.webhook_secret  = ENV.fetch("STRIPE_WEBHOOK_SECRET", nil)
    config.x.stripe.currency        = ENV.fetch("STRIPE_CURRENCY", "usd")

    config.x.mailchimp.api_key = ENV.fetch("MAILCHIMP_API_KEY", nil)
    config.x.mailchimp.list_id = ENV.fetch("MAILCHIMP_LIST_ID", nil)

    config.x.slack.webhook_url = ENV.fetch("SLACK_WEBHOOK_URL", nil)
    config.x.slack.channel     = ENV.fetch("SLACK_CHANNEL", "#general")

    config.x.sentry.dsn          = ENV.fetch("SENTRY_DSN", nil)
    config.x.sentry.sample_rate  = ENV.fetch("SENTRY_SAMPLE_RATE", "0.1").to_f
  end
end

Now the application has one file that lists every configuration value, with its source and default and any type coercion. Reading config/application.rb answers "what does this app need to run?" in 30 seconds.

Callers now read from the namespace:

# app/services/charge_user.rb
class ChargeUser
  def call(user, cents)
    stripe_config = Rails.application.config.x.stripe
    Stripe.api_key = stripe_config.secret_key
    Stripe::Charge.create(
      amount:   cents,
      currency: stripe_config.currency,
      customer: user.stripe_customer_id
    )
  end
end

# app/jobs/send_newsletter_job.rb
class SendNewsletterJob
  def perform(list_id)
    api_key = Rails.application.config.x.mailchimp.api_key
    Mailchimp::API.new(api_key).lists.members(list_id).each { |m| send_to(m.email) }
  end
end

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  def stripe
    secret = Rails.application.config.x.stripe.webhook_secret
    event  = Stripe::Webhook.construct_event(request.body.read,
                                              request.headers["Stripe-Signature"],
                                              secret)
    # ...
  end
end

The lookups are uniform. The type coercion happened once, in application.rb. The default for currency is in one place, not three.

Per-environment configuration

The config.x namespace lives on Rails.application.config, which means you can override it per environment in config/environments/*.rb:

# config/environments/development.rb
Rails.application.configure do
  config.x.slack.webhook_url = "https://hooks.slack.com/services/DEV/WEBHOOK"
  config.x.stripe.secret_key = "sk_test_dev_key"
end

# config/environments/test.rb
Rails.application.configure do
  config.x.slack.webhook_url = nil  # the notifier should fall back to in-memory
  config.x.stripe.secret_key = "sk_test_xxxxx"
end

# config/environments/production.rb
Rails.application.configure do
  # values come from real environment variables
  # set in application.rb, no overrides needed
end

The differences between dev, test, and prod live in the environment files where they belong. Production uses real ENV values. Development uses fake-but-functional ones. Tests use empty or known values. No code change needed; the environment file decides.

Going one step further: an explicit Config object

For apps with substantial configuration, Rails.application.config.x.stripe.secret_key gets verbose. The senior next step is to expose configuration through a small dedicated class:

# app/lib/app_config.rb
module AppConfig
  module Stripe
    def self.secret_key;     Rails.application.config.x.stripe.secret_key; end
    def self.webhook_secret; Rails.application.config.x.stripe.webhook_secret; end
    def self.currency;       Rails.application.config.x.stripe.currency; end
  end

  module Mailchimp
    def self.api_key; Rails.application.config.x.mailchimp.api_key; end
    def self.list_id; Rails.application.config.x.mailchimp.list_id; end
  end

  module Slack
    def self.webhook_url; Rails.application.config.x.slack.webhook_url; end
    def self.channel;     Rails.application.config.x.slack.channel; end
  end
end

# usage
AppConfig::Stripe.secret_key

The module is documentation as well as code. A new developer who wants to know "what Stripe configuration does this app use?" opens AppConfig::Stripe and sees three methods. The same module is the test seam — overriding any of those methods in a test stubs the value cleanly.

We used exactly this shape in the senior-track-lab learning POC: config/initializers/app_config.rb exposes AppConfig.github_app_id, AppConfig.openai_api_key, AppConfig.score_model. Every configuration value the app needs is one short method call, in one file, with a sensible default per environment.

Why this design holds up

Four wins, each one fixing a specific failure of the ENV.fetch-everywhere version.

One place to find every value. Onboarding documentation can point at config/application.rb (or the AppConfig module). "What environment variables does this app need?" has a real answer.

Tests do not depend on ENV. A test that exercises ChargeUser can override AppConfig::Stripe.secret_key to return a fake, or rely on the value already set in config/environments/test.rb. No ENV["STRIPE_SECRET_KEY"] dance in CI.

Type coercion is centralized. The string "0.1" gets converted to the float 0.1 once. Every caller sees a float. A forgotten .to_f cannot ship as a production bug.

Refactoring becomes safe. Renaming an environment variable is one edit in application.rb. Every caller continues to work because none of them named the variable.

When NOT to use this

Two cases where the central-config pattern is overkill:

  • One-off scripts or rake tasks. A migration script that uses ENV["UPLOAD_BUCKET"] in one place, runs once, and is deleted does not need the namespace. The pattern earns its weight when the value is read from many places.
  • Truly local-scoped configuration. A Sidekiq concurrency setting that only Sidekiq's config block reads is already centralized — there is one place that uses it. Moving it to config.x would not buy anything.

For secrets specifically: the Rails ecosystem also offers Rails.application.credentials (encrypted, committed to the repo) as an alternative to ENV. The two can coexist. Credentials are good for keys that should never leak; ENV is good for values that change per environment. Both work fine as the source feeding config.x; the namespace centralizes the read, not where the value comes from.

The principle at play

Configuration is a dependency. The Dependency Inversion Principle says high-level code should not depend directly on low-level details like environment variable names; it should depend on an abstraction. The config.x namespace is that abstraction. Callers ask for AppConfig::Stripe.secret_key; they do not care whether the value comes from ENV, encrypted credentials, a YAML file, or a hardcoded default.

The deeper move is to treat configuration as a single, named concept in the app, not as scattered string lookups. The integration with Stripe is "we have Stripe credentials and they look like this." That is a fact about the app, and it deserves a home in the codebase that does not require grepping to find.

The pragmatic value, the one that pays off every time a new developer joins: onboarding is reading one file. Not running grep -rn ENV.fetch app/ and hoping the output is complete.

Practice exercise

  1. Run grep -rEn "ENV\\[|ENV\\.fetch" app/ lib/ config/ | wc -l. The number that prints is your configuration sprawl.
  2. Group the matches by integration: Stripe, Mailchimp, Slack, whatever your app uses. For each integration, you should find the same values referenced in two or more files. That is the duplication centralization will eliminate.
  3. Sketch the config.x setup in config/application.rb: one block per integration, every value listed.
  4. Sketch the AppConfig module: one inner module per integration, one method per value. Imagine the moment a new developer joins your team and asks "what env vars does the app need?" The AppConfig module is your answer.
  5. Bonus: look at config/environments/test.rb. How many environment variables does CI need set? Every one of them is a place the central-config approach would simplify.