Creating Configuration Objects in Ruby

Written by: Daniel P. Clark

There are many kinds of applications and many ways to manage how they behave or perform via configurations. Software is designed with default behavior built in, so some specific configuration is usually required before the software works (like a path to reach a dependency), or a default and possibly rudimentary implementation may run without it.

From system tools that can have a central configuration for system-wide settings to a local user configuration to perhaps a per project config, the layers of configuration and the means by which they are stored can vary greatly. The choices for how you implement a configuration object should depend on where and how it will be used.

Simple Configuration Objects

Many simple applications won't need a robust system for configuring the application and can go with a simple Ruby object, such as a hash, and a stored configuration file format such as YAML.

A key-value store is the simplest concept of what a configuration object needs to be. And Ruby's Hash is the basic building block for all simple implementations of it -- whether it's a lone hash object or an object wrapping the hash with preferred behavior.

config = {}
config[:my_key] = :value
config[:my_key]
# => :value

A slightly more complex way is an object-oriented approach that dynamically defines methods to act as the keys and their return values as the values.

A common Ruby configuration object that takes the middle ground between these two approaches is the OpenStruct object. It uses a hash internally but also defines methods for each item you set so that they may be retrieved either way.

require 'ostruct'
config = OpenStruct.new
config.my_key = :value
config.my_key
# => :value
config[:my_key]
# => :value

A Ruby Hash will return nil as the default for a key that's used for lookup but hasn't been set yet. This is okay for rudimentary projects and simple designs, but it is not the OOP way, and on occasion you'll want something else as the default. Hash has a default_proc method on it from which you may define the default behavior for the return value as well as assigning to undefined keys.

If you wanted a hash to create deeply nested keys in one go, you could have the inner hash recursively return a new empty inner hash.

module MyProject
  def self._hash_proc
    ->hsh,key{
      hsh[key] = {}.tap do |h|
        h.default_proc = _hash_proc()
      end
    }
  end
  def self.config
    @config ||= begin
      hsh = Hash.new
      hsh.default_proc = _hash_proc()
      hsh
    end
  end
end
MyProject.config
# => {}
MyProject.config[:apple][:banana][:cherry] = 3
MyProject.config
# => {:apple=>{:banana=>{:cherry=>3}}}

But in this case, we've merely exchanged nil for an empty hash that hasn't met object-oriented programming standards. That's not a bad thing, and it's great for small or simple projects. However, the default value here has no meaning for us when it hasn't been set, so we have to build in guards and default behavior to work around this kind of model.

Rails provides an alternative kind of hash you can use called HashWithIndifferentAccess, which will allow you to use both a string or a symbol as the same key for a value.

config = HashWithIndifferentAccess.new
config["asdf"] = 4
config[:asdf]
# => 4

Better Configuration Objects

If you use Rails, you'll be happy to know that they have a system for configuration objects available for you to use -- and it's pretty simple.

class DatabaseConfig
  include ActiveSupport::Configurable
  config_accessor :database_api do
    DummyDatabaseAPI.new
  end
end

In the code above, the config_accessor creates the appropriate config methods on the DatabaseConfig class, and they will provide a DummyDatabaseAPI instance as the default value object. This is nice because we can define some default behavior when a proper database has not been configured and set yet. And to update the database API on the DatabaseConfig object instance, we need merely to call the setter method database_api=. Being explicit with configuration method keys and return duck-typed objects is good practice and should make the code base more enjoyable to work with in the future.

In a previous post, we covered “Creating Powerful Command Line Tools in Ruby”. It has some tools, such as slop, that take optional command-line input and give us a configuration object that includes defaults for anything not set via the command line. This is useful for when the configurations are few in number.

!Sign up for a free Codeship Account

Persisted Configuration

Persisted configuration is the type of configuration that is either hard-coded in the program, stored in a file, or stored in a database. Most configurations are intended to be handled this way, although some configurations are set in environment variables or an encrypted equivalent.

Ruby comes with YAML support, which is a configuration favorite among many people. You may set where you'd like to have the configuration file loaded from and make that available in the README or documentation for easy-to-read configuration for your end users or yourself. Rails keeps many YAML files under a config directory; Linux applications may have them in the project directory, in the users home directory hidden under .config/project_name, or sometimes a systems config location such as /etc. So there are many places where you may choose to have them reside.

A YAML file looks as simple as:

---
# A list of tasty fruits
fruits:
  - Apple
  - Orange
  - Strawberry
  - Mango

And in Ruby, to load this into an easy to access Hash:

require 'yaml'
YAML.load(
  File.open('example.yml').read
)
# => {"fruits"=>["Apple", "Orange", "Strawberry", "Mango"]}

If you want, you may produce YAML from a Ruby Hash instance with the to_yaml method. This is available after require yaml or via YAML.dump.

config = {
  "default" => {
    "adapter"=>"postgres",
    "encoding"=>"utf8",
    "pool"=>5,
    "timeout"=>5000
  }
}
puts config.to_yaml
# ---
# default:
#   adapter: postgres
#   encoding: utf8
#   pool: 5
#   timeout: 5000

Generally you won't be writing YAML to configuration files from Ruby unless it's a first-time setup. Even then, YAML files are generally provided along with a project rather than written from one.

Environment Variables

One thing you'll want to avoid is having environment variable checks scattered across your code base. Application configuration should be centralized to avoid the potential of surprise behavior when states change.

YAML doesn't provide a way to retrieve environment variables on its own. But Ruby includes the ERB template engine, which we may use as an additional layer of processing for our configuration files. With ERB, we can run Ruby code within and block designated with <%= %>.

# # YAML file
# production:
#   secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
# # End YAML file
require 'yaml'
require 'erb'
yaml_contents = File.open('config.yml').read
config = YAML.load( ERB.new(yaml_contents).result )
config
# => {"production"=>{"secret_key_base"=>"uih...943"}}

This keeps the more human readable configuration file and centralizes everything that belongs together into one place.

Precedence

When an application has a system-wide configuration as well as a user local configuration, the user local typically takes precedence.

You can choose several different behaviors for this. You could ignore the system config if a local one exists, or you could override only specific system configurations if they're provided locally. It's possible that some configurations have priority for the system config first, and others for the user, although this is an usual scenario.

If one simply overwrites the other, you can merge the hash results of the configurations.

These examples will assume YAML loading has already been done and demonstrate different hash precedence techniques.

system_conf = {root: false, name: "root"}
user_conf = {root: true}
# Completely overwrite any system config from user config
config = system_conf.merge(user_conf)
config
# => {:root=>true, :name=>"root"}

Another approach can be to use Hash#dig and prioritize what settings get called in what order.

class Config
  def system_conf # Assume loaded from YAML
    @system_conf ||= {root: false, name: "root"}
  end
  def user_conf # Assume loaded from YAML
    @user_conf ||= {root: true}
  end
  def name
    user_conf.dig(:name) ||
      system_conf.dig(:name)
  end
  def root?
    system_conf.dig(:root)
  end
end
config = Config.new
config.name
# => root
config.root?
# => false

In this example, we were able to prevent the user from overriding his status as root. And even though the user didn't provide a name, the config fell back to the default provided from the system configuration.

Summary

Creating configuration object(s) for your own project need not be difficult but will often require much consideration in its implementation for the scope and size of the project you're building.

If your project is rather large and you may pivot to an alternative way of handling configurations in the future, then you may seriously want to consider implementing your configuration system through Ruby Object Mapper. ROM is a uniform way to process data across a myriad of data storage types like YAML, as well as many databases. It offers flexibility with a minimal learning curve. If your project is small, then a simpler solution may be a better fit.

When it comes to implementing configuration systems in Ruby, the world is truly your oyster.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.