[Feature Proposal] Syntactic Sugar for accessing Configuration, Credentials, and ENV

Rails has some great support for initializing configuration/secrets/credentials values, but I’ve always struggled a little with accessing the values. Not a deep struggle, but some frequent friction on the differences between how credentials and configuration are exposed. And using ENV has always felt a little inelegant in certain contexts as well.

I’ve shared the implementation details, but the implementation would have to evolve significantly if this or something similar was a good fit to buld into Rails. The interface and resulting benefits was the piece that felt worth discussing.

Onto the idea…

As it stands now, looking at these side-by-side, despite similarity in use cases, there’s not much consistency. While that’s not inherently a bad thing, it created an itch I wanted to scratch.

Rails.application.credentials.secret_key_base
Rails.application.credentials.aws[:secret_key]
Rails.configuration.settings[:public_key]
Rails.configuration.settings[:domain][:name]
ENV['ENV_VAR']
ENV.fetch('ENV_VAR', 'Default')
ENV.fetch('ENV_VAR') { 'Default' }

I thought it might be handy to have source-indifferent lookups that created a more consistent interface for retrieving the values without having to think as much about nuances of where they were stored or how they’re supposed to be read.

There’s a variety of pros/cons, but here’s the rough idea:

Settings.optional(:key)                   # => Returns the value
Settings.optional(:key_one, :key_two)     # => Returns the value of the nested keys
Settings.optional(:missing)               # => Returns nil
Settings.required(:key)                   # => Returns the value
Settings.required(:key_one, :key_two)     # => Returns the value of the nested keys
Settings.required(:missing)               # => Raises exception when a critical value is missing
Settings.default(:key) { 'Default' }      # => Returns the value and ignores the default.
Settings.default(:missing) { 'Default' }  # => Returns 'Default'

The benefits I’ve found from this approach…

  1. The interface for retrieving the value is consistent regardless of the source of the value. I spend less time thinking about syntax, return values, and defaults.
  2. Accessing the values focuses more on how they’ll be used in context rather than the source of the value. In some cases, for instance, if a value is missing, the application can still run just fine. In other cases, if a value is missing (due to environment differences, typos, or whatever), it’s best to stop right away and draw attention to the fact that the value is missing. Otherwise, a nil configuration setting can be passed around in the code and not fail until it’s much less clear where the problem originated.
  3. It’s a little more intention-revealing about the expectation for how the value will be used.
  4. With defaults, it extends a similar approach regardless of the return value for the setting. There’s no need to think about which approach for providing a default return value is appropriate. i.e. || or fetch(:key, 'Default') or fetch(:key) { 'Default' }.
  5. In cases where production/staging rely on Environment Variables like Heroku’s Config Vars, the retrieval can work independently so that test/development can explicitly ignore the value or find it via configuration instead.
  6. Maybe it could be extended to help reduce ambiguity between the various simultaneous approaches to secrets/credentials?
  7. It provides a home for some richer exceptions to help catch configuration issues earlier and reduce debugging second-order effects of configuration value issues.
  8. If all else fails the existing ways of accessing values would still be available.

The caveats/room for improvement…

  1. Performance. It is currently slower due to the extra level of indirection. In real-world usage for these kinds of values, it seems likely to be negligible relative to the benefits it provides. (Benchmarks at the end.) Or an integrated Rails solution could potentially address that.
  2. Abstracting the source from lookups could be problematic. Sometimes, the friction is a good thing and explicitly implementing the lookup logic in the context it’s used can be helpful. While this wouldn’t be prevented, it becomes less obvious that it’s an option to direclty access the values.
  3. If this existed in parallel to the existing options rather than replacing them, it could create more ambiguity and confusion about which method is the best method.
  4. As it stands, it’s far from something that could be dropped right into Rails, but it seemed worth discussing about whether a built-in Rails equivalent could be worth exploring further. The existing approach was more of a way to try it out in practice to see if it felt better. i.e. Settings would need to live off of Rails rather than be a standalone class among other things.
  5. It does not currently account for the Rails.configuration.x.{method} approach–only the Rails.configuration.{method}. I don’t believe that would be difficult to add support for, though.
  6. There are some additional access methods via method_missing and source-specific options as well. They’re documented in the source code. (Linked below with benchmarks.) I can’t say I have strong feelings yet about whether all of the approaches are necessary or their relative usefulness.
  7. It is possible to have ‘conflicts’ where there’s a key or set of keys that exist in more than one source. Right now, when this happens, it raises an exception to draw attention to the problem. Then the conflicting key can be renamed, deleted in which ever file it shouldn’t be in, or accessed via the source-specific options. i.e. Settings.secret(:key), Settings.config(:key), or Settings.env(:key).
  8. This implementation behaves as a wrapper, but a better approach for Rails may be to expose more consistent access methods to each of the sources rather than route access through a new layer.
  9. It doesn’t currently account for scenarios where the optional/required/default approach could differ by environment. That’s likely not a bad thing, but it could be an opportunity to make it a little more elegant. Or it could complicate matters and make the resulting interface more confusing.
  10. Loading/initialization order means the Settings class isn’t currently accessible early in the boot process, but I believe that’s a solvable problem, especially if this was more natively integrated with Rails.

While this implementation leaves plenty to be desired, the interface and benefits feel worth pursuing/exploring in the larger context of Rails.

Benchmark based on 1,000,000 runs. Given how configuration/secrets are generally accessed, this may be less relevant than benchmarks for things like ActiveRecord, but it is technically slower. There are likely some good opportunities to improve the performance as this implementation focused only on the interface for accessing the values without much thought to performance.

Direct   One Level               0.826228   0.006609   0.832837 (  0.832929)
Direct   Two Levels              0.903825   0.000150   0.903975 (  0.903999)
Settings One Level  .config      1.267055   0.000227   1.267282 (  1.267377)
Settings Two Levels .config      1.524995   0.000356   1.525351 (  1.525690)
Settings One Level  .optional    4.300620   0.001423   4.302043 (  4.302907)
Settings Two Levels .optional    4.917827   0.001853   4.919680 (  4.921652)
Settings One Level  .required    4.830326   0.006278   4.836604 (  4.841879)
Settings Two Levels .required    5.209971   0.008416   5.218387 (  5.225179)
Settings One Level  .default     4.832007   0.010729   4.842736 (  4.854616)
Settings Two Levels .default     5.267153   0.009141   5.276294 (  5.286929)
Settings Miss L1    .default     4.666976   0.005361   4.672337 (  4.675439)
Settings Miss L2    .default     5.549376   0.012397   5.561773 (  5.565845)
Settings L1  .method_missing     4.693234   0.006376   4.699610 (  4.702111)
Settings l2  .method_missing     4.868722   0.005042   4.873764 (  4.876025)

Benchmark samples:

n = 1000000
Benchmark.benchmark(CAPTION, 30, FORMAT, ">total:", ">avg:") do |x|
  x.report('Direct   One Level') { n.times do; Rails.configuration.settings[:test_one]; end }
  x.report('Direct   Two Levels') { n.times do; Rails.configuration.settings[:test_two][:test_three]; end }
  x.report('Settings One Level  .config') { n.times do; Settings.config(:test_one); end }
  x.report('Settings Two Levels .config') { n.times do; Settings.config(:test_two, :test_three); end }
  x.report('Settings One Level  .optional') { n.times do; Settings.optional(:test_one); end }
  x.report('Settings Two Levels .optional') { n.times do; Settings.optional(:test_two, :test_three); end }
  x.report('Settings One Level  .required') { n.times do; Settings.required(:test_one); end }
  x.report('Settings Two Levels .required') { n.times do; Settings.required(:test_two, :test_three); end }
  x.report('Settings One Level  .default') { n.times do; Settings.default(:test_one) { 'Default' }; end }
  x.report('Settings Two Levels .default') { n.times do; Settings.default(:test_two, :test_three) { 'Default' }; end }
  x.report('Settings Miss L1    .default') { n.times do; Settings.default(:missing) { 'Default' }; end }
  x.report('Settings Miss L2    .default') { n.times do; Settings.default(:test_two, :missing) { 'Default' }; end }
  x.report('Settings L1  .method_missing') { n.times do; Settings.test_one; end }
  x.report('Settings l2  .method_missing') { n.times do; Settings.test_two[:test_three]; end }
end

Source: https://github.com/adaptable-org/adaptable/blob/main/app/lib/settings.rb

Tests: https://github.com/adaptable-org/adaptable/blob/main/test/lib/settings_test.rb

1 Like

Hey Garrett! Have you seen anyway_config? If I got you right it solves most of the pain points you described (though has also an important difference: having multiple config classes instead a single one, Settings).

2 Likes

Thanks, Vlad. I had not seen it. There’s some pretty interesting ideas in there, and it’s always reassuring to know when you’re not alone with specific challenges. :slight_smile:

I had seen rubyconfig, but in general, I’m dependency-shy. With configuration-level things, I’d lean towards sticking with the Rails basics rather than add another dependency, especially when that dependency lives at such a fundamental layer of the application.

Even my exploratory implementation gives me pause vs. directly using the existing Rails conventions. That’s the reason it only focuses on the read side of configuration values and not the setting/loading/initialization.

It felt interesting enough for a broader discussion, but as it stands, even this sliver focused only on reading feels a little heavy-handed. I figured if the concept was interesting enough, that with feedback, it could be adjusted or integrated a bit more gracefully to overcome its shortcomings…