In this article, I would like to suggest the use of a very common pattern in Go, Functional Options, but adapted to the Ruby language.
As an example, you can see it used in OpenTelemetry Go
The problem
Here is a slightly modified example of a method definition from a Ruby component owned by my team:
def initialize(component:, environment:, version: nil, instance: nil, io: nil, vendorA_api_key:, vendorB_api_key:)
The number of attributes is quite high, and is meant to keep growing, as we
could want to support more vendors
Most of the time, components using this library will use only a portion of
those arguments, not all of them.
We want to keep this method readable. So, how can we reduce the number of arguments?
Hash Options
We could replace the method with a configuration hash, so we have a single argument.
def initialize(options = {})
This way of handling options has its advantages. We now end up with a single argument, and we can extend the options as much as we need to.
However, it has a few drawbacks as well. Specifically, defaults will be harder to handle. We will need to handle the case of an unset option when we need to use it, as opposed to as close to where the option is defined.
Ruby passes all its variables by value. So we don’t have the issue of changing the content of our options hash after it has been passed to the initializer. However, that’s something which could be quite confusing to read:
options = { api_key: "abcd" }
MyLibrary.new(options)
options[:api_key] = "def" # What happens here?
Functional Options
What if we could do the following:
MyLibrary.new(MyLibrary.with_api_key("abcd"), MyLibrary.with_environment("development"))
I am passing all my options in a quite explicit way, there’s no outstanding variable which can later be modified, and I can definie the behavior of that option as soon as it’s being called, not later.
An implementation
Here is an implementation proposal:
require "ostruct"
# Usage: MyLibrary.new(MyLibrary.with_api_key("abcd"))
class MyLibrary
def self.with_api_key(key)
lambda do |klass|
vendor = OpenStruct.new(key: key) # In a real world, we could use any logic to set the vendor class
klass.send(:add_vendor, vendor)
end
end
def initialize(*opts)
@vendors = []
opts.each do |opt|
opt.call(self)
end
end
private
def add_vendor(v)
@vendors << v
end
end
What happens here?
First, we define one option method, with_api_key
. That method returns a
lambda, where the attribute is the class needing to be configured.
While we are outside the context of the method, and therefore cannot call
private methods, I am taking the liberty of using call
to do so anyway, as we
are technically within the same namespace.
In initialize
, we accept a single argument: an infinite number of options,
each of which needs to be a lambda accepting the class as its argument.
We then loop through all those lambdas and apply them to our class, which will
run the appropriate configuration.
Conclusion
I am finding this pattern to be very elegant in Go, as well as in Ruby. It
allows specifying a large number of very options to any class, and permits
setting the logic for those options right where they are defined (in the
with_api_key
method) rather than in separate places of the method
instanciation.