Portable feature flags in ten-ish lines of Ruby
It may make sense to use a mature feature flag library or pay for feature flags as a service in your complex application. That said, you can build pretty robust feature flags in just a few lines of Ruby:
require "json"
module Features
class << self
def as_json
flags = self.public_methods - Object.public_methods - [:as_json]
flags
.to_h { |method|
[method.to_s.sub("?", ""), self.public_send(method)]
}
.to_json
end
def éric_rohmer_mode_enabled? = enabled?("ERIC_ROHMER_MODE")
private def enabled?(env_string)
return false if (value = ENV[env_string]).nil?
value != "false" && value.length != 0
end
end
In this example, we track environment variables for each of our feature flags
(just one, for now: ERIC_ROHMER_MODE). The interface returns a boolean
value when we ask whether the given flag is enabled. Because we’re managing
the flags within a single module, we can do nice things such as introduce
the Features.as_json method to the current enabled status of all of our
feature flags, meaning we can send this data to other systems and to web
clients more easily:
Features.as_json
=> "{\"éric_rohmer_mode_enabled\":false}"
If you don’t need the portability of JSON, you can write even fewer lines of Ruby to get the same functionality by relying on truthy and falsey outputs instead of booleans.
As long as you keep higher-complexity methods like Features.as_json to
a minimum, this module can easily grow with you as your need for weirder
feature flags increases. If some features should only be available to users
with a beta tester role, or you want to expose some functionality when a
certain URL parameter is present, it’s just a matter of injecting your user
object and your request parameters object:
require "date"
require "json"
module Features
ÉRIC_ROHMER_LAUNCH_DAY = Date.new(1920, 03, 21)
NULL_SCOPE = {
url_params: {},
user: nil
}
class << self
def as_json(scope = NULL_SCOPE)
flags = self.public_methods - Object.public_methods - [:as_json]
flags
.to_h { |method|
[method.to_s.sub("?", ""), self.public_send(method, **scope)]
}
.to_json
end
def éric_rohmer_mode_enabled?(_)
enabled?("ERIC_ROHMER_MODE") &&
Time.now.to_date >= ÉRIC_ROHMER_LAUNCH_DAY
end
def infinite_money_enabled?(user:, **)
enabled?("INFINITE_MONEY") && !user.nil? && user.beta_tester?
end
def preview_mode_enabled?(url_params:, **)
enabled?("PREVIEW_MODE") && url_params[:preview] == "yes"
end
private def enabled?(env_string)
return false if (value = ENV[env_string]).nil?
value != "false" && value.length != 0
end
end
end
As long as you’re keeping track of potential dependencies (NULL_SCOPE in
this example) and passing in reasonable default values, Features.as_json
still works sans arguments, and each flag check can independently be as
complex as you’d like it to be.
JSON.parse Features.as_json
=>
{"éric_rohmer_mode_enabled"=>false,
"preview_mode_enabled"=>false,
"infinite_money_enabled"=>false}
JSON.parse Features.as_json(user: beta_tester, url_params: {})
=>
{"éric_rohmer_mode_enabled"=>false,
"preview_mode_enabled"=>false,
"infinite_money_enabled"=>true}
I’ve used keyword arguments in my interface because I prefer them to positional arguments. This ensures the flag method arguments remain legible in your application code and are easy comprehend by other readers. But as you can see, keyword arguments make flags a bit more annoying to write once you’ve introduced dependencies.
The most awkward thing here is that even feature flags methods that
don’t require dependency injection must define an argument (_ or
_unused). The second most awkward thing is that, only because we’re
using keyword arguments, we need to pass a double-splat argument (**)
to ensure, when Features.as_json is computing the given dependencies for
each flag dynamically, irrelevant arguments are ignored without raising an
ArgumentError. Luckily, the relevant dependencies remain very, very visible
to anyone else reading.
Every codebase I’ve worked on deals with feature flags a little bit differently. I’d love to hear about what’s different in your application’s. (Send me a message!)