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!)