The Options Pattern provides a solution for making your applications configurable. The name of this pattern stems from .NET but we’ll learn how to implement this in Ruby.
Quick Start
To make any object configurable, you only need Structs to define the attributes you need and a class to inject the configuration into. Example:
Configuration = Struct.new :token, :uri
class Client
def initialize(settings = Configuration.new)
@settings = settings
yield settings if block_given?
end
end
Client.new do |settings|
settings.uri = "https://api.demo.io"
settings.token = "secret"
end
The above will produce an instance of a Client as follows:
#<Client:0x00000001287f53e0
# @settings=#<struct Configuration token="secret",
# uri="https://api.demo.io">
#>
The beauty of this pattern is that you use a Struct, as a whole value object, to define all attributes of your configuration and then inject that configuration — using Dependency Injection (DI) — into an object that needs the configuration. Yielding (i.e. yield settings) if a block is given is what makes your object configurable because, unlike Data objects, you can mutate a Struct.
These are the basic principles, now let’s learn how to expand upon this pattern further.
Advanced
With the basics in mind, we can improve upon this pattern by looking at additional objects and complimentary patterns for which you can architect more robust solutions. We’ll start by taking another look at the Configuration object.
Configuration
In the Quick Start example above, we used a Struct to define the attributes of our configuration. You could also enhance this object with defaults:
Configuration = Struct.new :token, :uri do
def initialize(**attributes)
super
self[:uri] ||= "https://api.demo.io"
end
end
The problem with the above is there isn’t a great way to provide a default value for the token attribute which ends up being nil because the value should be an API token unique to each instance. One way to address this is to use environment variables for greater customization and flexibility:
Configuration = Struct.new :token, :uri, :environment do
def initialize(environment: ENV, **attributes)
super
self[:token] ||= environment["API_TOKEN"]
self[:uri] ||= environment.fetch("API_URI", "https://api.demo.io")
end
end
With the above, we can see that if no token or uri value is supplied, we’ll fall back to what is supplied by the environment. In the case of token this’ll still be nil if nothing exists but with the uri, we’ll fall back to "https://api.demo.io". Definitely a step in the right direction but there are problems with this approach:
-
The constructor now has a lot more setup behavior which isn’t necessarily a bad thing but definitely gets worse if more attributes are added.
-
Dependency Injection (DI) is used for
ENVwhich allows for customization but, in this case, is too clever because there is a Connascence of Position problem should theenvironmentattribute change position which could cause it not be set in time to provide fall backs for thetokenanduriattributes. This can lead to surprising behavior and/or subtle bugs which are worth avoiding.
There’s a better way to handle this which is to use a dedicated loader as explained below.
Loader
Having a dedicated loader allows you to split the behavior of loading your configuration from construction of your configuration and opens up the possibility of having different loaders for the same configuration. Example:
Configuration = Struct.new :token, :uri
class Loader
def initialize model: Configuration, environment: ENV
@model = model
@environment = environment
end
def call
model[
token: environment["API_TOKEN"],
uri: environment.fetch("API_URI", "https://api.demo.io")
]
end
private
attr_reader :model, :environment
end
Notice how the loader is injected, upon initialization, with the model and the environment giving you full flexibility to inject different objects with the same behavior if desired. This is the power of Dependency Injection (DI). Then, all you have to do is initialize and call the loader in order to build a configuration with safe defaults.
You can take this a step further by adding data transformations, validations, and more to the entire process. This is exactly what Etcher was created for so check out the gem documentation to learn more on how nice this can be for loading of advanced configurations.
Dependency Injection
With your configuration and loader in hand, you can now use a dependency container, like Containable, and automatic injection, like Infusible, to load and memoize your configuration for use across multiple objects if desired. Example:
module Container
extend Containable
register(:settings) { Loader.new.call }
end
Dependencies = Infusible[Container]
The above loads your settings with safe defaults once and only once but you can always force this to happen each time by using register(:settings, as: :fresh) { Loader.new.call }. See the Containable documentation for further details, though.
Example
By using everything discussed thus far, we can apply this pattern in full force by building a simple, but fault tolerant, GitHub API client. The implementation below is a Bundler Inline script so you can immediately experiment with the implementation:
A GitHub Client Implementation
#! /usr/bin/env ruby
# frozen_string_literal: true
# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.
require "bundler/inline"
gemfile true do
source "https://rubygems.org"
gem "amazing_print"
gem "containable"
gem "debug"
gem "dry-monads"
gem "http"
gem "infusible"
end
Configuration = Struct.new :token, :uri do
def initialize(**attributes)
super
self[:token] ||= "secret"
self[:uri] ||= "https://demo.api.io"
end
end
class Loader
def initialize model: Configuration, environment: ENV
@model = model
@environment = environment
end
def call
model[
token: environment["API_TOKEN"],
uri: environment.fetch("API_URI", "https://api.demo.io")
]
end
private
attr_reader :model, :environment
end
module Container
extend Containable
register(:settings) { Loader.new.call }
register :http, HTTP
end
Dependencies = Infusible[Container]
class Client
include Dry::Monads[:result]
include Dependencies[:settings, :http]
def initialize(**)
super(**)
yield settings if block_given?
end
def get(path, **params) = call(__method__, path, params:)
def post(path, **json) = call(__method__, path, json:)
private
attr_reader :settings, :http
def call verb, path, **options
http.auth("Bearer #{settings.token}")
.public_send(verb, "#{settings.uri}/#{path}", options)
.then { |response| response.status.success? ? Success(response) : Failure(response) }
end
end
client = Client.new do |settings|
settings.uri = "https://api.github.com"
settings.token = "<your API token>"
end
include Dry::Monads[:result]
case client.get "users"
in Success(response) then puts response.parse
in Failure(message) then puts message
else puts "Unknown type."
end
You’ll notice that the above implementation uses all objects discussed:
-
Configuration -
Loader -
Container -
Dependencies -
Client
At a minimum, you only need the Configuration and Client (or whatever object you wish to inject the Configuration into) but the other objects show how you can make all of this reusable.
Advantages
The advantages of this pattern are:
-
Uses whole value objects (i.e. Structs) to define all attributes of your configuration which is great for equality and quick access to reading and writing individual attributes.
-
Uses the Single Responsibility Principle (SRP) which is the
Sin SOLID design to keep housing configuration data separate from the actual loading and customization of the data. -
Uses Dependency Injection (DI) which is the
Din SOLID design that makes testing a breeze by swapping out different configurations as long as they have the same basic behavior. -
Attributes can be modified after any defaults are loaded if desired.
-
Provides a flexible way in which to configure individual or multiple objects that might need to share the same configuration.
Disadvantages
There are few disadvantages to using this pattern. You could argue that mutation is involved because we can mutate the Struct via a block upon initialization but, remember, mutation works to our advantage in this case because the configuration instance is isolated to the object it’s injected into.
Guidelines
Avoid using classes to implement your configuration. Example:
class Configuration
attr_accessor :token, :uri
end
The above is an antipattern because the class has no state, behavior, and/or functionality other than housing attributes. That’s not what classes are meant for and is why Structs exist to encapsulate raw data like this.
Another mistake people make is storing configuration logic at the class level instead of instance level. Example:
class Client
def self.configuration = @configuration ||= Configuration.new
def self.configure = yield configuration
end
There are multiple issues with the above:
-
This isn’t what a class is for. A
Moduleor a container (i.e. Containable) would be better. -
The configuration is now global and shared across all objects.
-
Introduces a Connascence of Name issue due
Configurationbeing hard coded into.configurationclass method which creates a tight coupling to theConfigurationclass. To modify any of this, you’d have to alter the source code of the class method itself. -
Will error if no block is given to the
.configuremethod.
All of this is avoidable by using object composition to inject your dependencies instead as denoted by use of the Containable and Infusible gems mentioned above.
Examples
Should you need more examples to see this pattern in practice, here are a few gems that leverage this pattern.
-
APIs
-
CLIs
-
Gemsmith: For building new gems and, if you use the
--cliflag when building CLI specific gems, this pattern will be applied automatically for you. -
Pennyworth: Enhances your Alfred workflows with Ruby firepower.
-
Milestoner: Allows you to automatically version and generate release notes for your softare releases.
-
Conclusion
This pattern is simple in implementation and use while providing a lot of flexibility in making your applications simple to configure with nice fallback support for environment variables if desired. Even better, you can use more advanced configuration loading techniques as provided by the Etcher gem.
If this pattern isn’t already part of your toolbox then hopefully the next time you need to make your objects configurable, you can apply this pattern to build a robust solution with grace. Enjoy!