Better Settings in Ruby apps

AuthorMáximo Mussini
·6 min read

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

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 (specially String, Hash, and Array values).

For example, when working with a Hash or Array setting, calling merge! or 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.

Error-prone design

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 plain 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 Hash methods.

We go through every hash entry, processing any nested Array and Hash objects, 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 NoMethodError.

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 root_settings 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: development.yml and 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

Having a 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 fail.

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.

Environment Variables

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 application.yml:

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.

Summary

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.