BetterSettings is a settings-management library for Ruby apps, which was designed in response to certain issues we faced when using settingslogic (one of the most popular libraries to manage settings in Rails applications).
In this post we’ll talk about settingslogic and its design decisions, how they affect reliability, and how we can overcome them.
Even if you are not familiar with these libraries, reading this article might help you to learn about potentially harmful practices, and how to avoid them in your own code.
Settingslogic can read a .yml file and turn it into a Ruby object, which provides access to settings by indexing it as a Hash, or by using method calls.
Settings can be modified
The library aims to be flexible, by allowing to create a setting that is not present in the file or modify an existing one, by using:
Settings['property'] ||= 'value'
Although it might seem like a good idea, this behavior introduces uncertainty about whether a setting will be available or not when accessing it, and the value it might have, since that depends on the execution order of the program.
Because the library is not thread-safe, modifying settings makes them suceptible to race conditions in multi-threaded apps or multi-threaded app containers.
Values are mutable
Even when not creating or modifying a setting at runtime, the values read from
the .yml file can be accidentally modified since the settings are not frozen
For example, when working with a
Array setting, calling
push causes the setting to be changed on every subsequent access, which can
have unpredictable consequences in the application.
These accidental mutations are usually very hard to detect, and can cause bugs that are difficult to replicate and track down.
Writing code carefully to avoid mutating the data structures is not enough, since we have no guarantee that third-party libraries will be as careful. To solve this we would need to always clone settings before handing them over, which is cumbersome and error-prone.
Limited to a single file
Only one .yml file can be specified, so there’s no way to read configuration from multiple files. Because settings are necessary to run the application, it’s important that the file is versioned under source control.
In our case, this meant that when developers changed a setting based on their local setup, or to perform a manual test, they had to manually skip it when making a commit.
As a result, many times a developer would accidentally push these changes that should not be merged upstream, and we had to be on the lookout during code reviews to prevent unwanted changes.
These problems we experienced with settingslogic, related with reliability and collaboration, are a consequence of the design decisions in the library.
The library defaults are troublesome, such as deferring the load of the .yml file until the settings are accessed, making it possible for an app to start successfully and fail later at runtime.
In the end, it has the same disadvantages of simpler approaches, like using a
OpenStruct to manage settings.
A Better Way
With that in mind, we decided to design a new solution from scratch, that could handle these shortcomings, and actively prevent bugs and misusage.
The result is
BetterSettings, designed after the following concerns:
- Predictability: Once created, settings can not be added or modified, which prevents race conditions and makes usage safe and predictable.
- Immutability: All setting values are frozen, preventing an entire category of bugs related to accidental mutation.
- Multiple sources: Settings can be read from different files. This allows to split settings as needed, or create additional files for development purposes.
- Better errors: Accessing a missing setting is treated as an error, helping developers to easily detect typos and other mistakes with a clear error message.
- Fail-fast: Source files should be eager loaded by default, so that problems in the environment are detected during deployment.
The setup for the .yml file is very similar to settingslogic, you can find more information and examples in the README.
How does it work?
Internally, settings are stored in a frozen
Hash, which is an instance
variable in the
BetterSettings object. We delegate
to_h to this internal
hash for easy access, but other than that, we don’t expose any
We go through every hash entry, processing any nested
freezing every wrapped value, setting it in an instance variable, and making it
accessible by defining a getter for that key.
It’s worth noting that we wrap nested
Hash values in instances of your
BetterSettings class, which will recursively repeat the process.
As a result, the entire settings graph is readable but immutable, and each nested object exposes getters for the available keys.
To make errors a bit friendlier, we implement
method_missing to provide
context on where a setting is missing, instead of an unhelpful
Finally, we sprinkle some syntax sugar by making every top-level key available
as a method in the
Settings class, by delegating it to a
instance that is populated when calling
source in the class.
Reading from multiple files ⚙
Not being limited to a single source file opens up the possiblities.
We usually read two optional files:
test.yml that are
loaded in the development and test environments respectively, allowing each
developer to easily override settings in their local setup.
In a Rails app with the typical setup, the configuration looks like this:
class Settings < BetterSettings source Rails.root.join('config/application.yml'), namespace: Rails.env if Rails.env.development? source Rails.root.join('config/development.yml'), namespace: Rails.env, optional: true end if Rails.env.test? source Rails.root.join('config/test.yml'), namespace: Rails.env, optional: true end end
development.yml file comes in handy when making changes that should
not be shared, such as a temporary change to test a different configuration, or
a permanent one that is only relevant in a specific local setup (such as
different host names or port numbers).
This could also be achieved by reading environment variables (see the next section), but for development using an optional file is more convenient, as it’s located in the same folder than the main one, and settings can be copied and tweaked.
On the other hand,
test.yml makes it possible to configure tests to run with a
different formatter for the results, or configure optional behavior, like
automatically opening the screenshots that are captured when integration tests
This flexibility enables us to provide awesome defaults, while still allowing everyone to modify the configuration according to their personal preference or local setup, without having to worry about pushing those changes by accident.
In server environments, such as staging and production, we use environment variables for any sensitive information, such as passwords.
In order to ensure that missing environment variables are quickly detected, we use this simple helper:
module Env # Public: Read an environment variable by name. # NOTE: Defaults are only used in the development and test environments. def self.require(*args, &block) if Rails.env.development? || Rails.env.test? ENV.fetch(*args, &block) else ENV.fetch(args.first) end end end
and then in
mailer: <%= Env.require('MAILER_PORT', 587) %>
By using this helper, we can be very strict on servers－where we need
everything to be configured (such as hosts, ports, and third-party integrations)－and
lenient in development－where we can just provide a default value and then
override it by using
development.yml if necessary.
We prefer this pattern over using
ENV.fetch with a default value as a
fallback, since that would cover up a missing environment variable in the servers.
So there you have it, BetterSettings is a settings solution for Ruby apps that encourages good practices, is friendlier for team collaboration and source-control, and prevents bugs.
BetterSettings: simple, immutable, better.