Better Settings in Ruby apps

March 02, 2019 7 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:

The setup for the .yml file is very similar to settingslogic, you can find more information and examples in the README.

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.

By @Maximo @MaximoMussini