home articles sponsorship

Build a minimal feature flags manager in under an hour

When you deploy your code continuously, feature flags (or feature toggles) are a neat way to hide in-progress features from your users. In the past, I’ve written about my process for releasing large features. But after using feature flags in the last six months, I’ve come to like them better than my former process.

Still, hiding behavior behind if ENV['MY-FEATURE'].present?-type conditionals bothers me.

First, I don’t think the syntax is very legible. Second, I know it’s hard to maintain a naming convention across a team. You’ll soon end up with endless variations: ENV['MY-FEATURE], ENV[FF-MY-FEATURE-NAME], ENV['TOGGLE-FEATURE'], etc… These discrepancies make it hard to keep track of all your feature flags.

Sure, you could use a gem or a third-party service. But for those who just need an on-and-off system, here’s a minimal feature flag manager that’ll take you less than an hour to build. It’s a plain Ruby object but its configuration leans on Ruby on Rails. I’m sure a Ruby-only implementation wouldn’t be too hard to whip up, though.

Expected outcome

Instead of if ENV['EDITORIAL_FEED_FEATURE'].present?, I’d like to see this syntax:

  if Features.enabled? :editorial_feed

While looking at it, a few ideas come to mind:

A basic implementation of my Features class could be this:

  class Features
    def self.enabled?(feature)
      # check if _feature_ is in the list of flags AND enabled
    end
  end

A shameless green version

What behavior should we expect from our Features class?

If you pass a feature’s name to Features.enabled?, the method should:

Let’s draft our first test.

  RSpec.describe Features do
    describe '.enabled?' do
      subject { described_class.enabled?(feature) }

      let(:feature) { :my_feature }

      it { is_expected.to be_in([true, false]) }
    end
  end

A minimal implementation could be:

  class Features
    FEATURES = {
      audiobooks: false,
      comic_strips: true,
      editorial_feed: true
    }.freeze

    def self.enabled?(feature)
      FEATURES[feature.to_sym]
    end
  end

This code is problematic on several levels. Our test is green, though. If you were to put this code into the world right now, it’ll work. I also like that all feature flags are neatly organized in one place.

Some of the problems we can guess:

Let’s try and tackle the latter first.

Use environment variables

The smallest step we can take is to replace hardcoded values with environment variables.

  class Features
    FEATURES = {
      audiobooks: ENV.fetch('AUDIOBOOKS_FEATURE', false),
      comic_strips: ENV.fetch('COMIC_STRIPS_FEATURE', false),
      editorial_feed: ENV.fetch('EDITORIAL_FEED_FEATURE', false)
    }.freeze

    def self.enabled?(feature)
      FEATURES[feature.to_sym]
    end
  end

With this code, our test still passes but not for the right reasons.

The application evaluates the presence of environment variables at runtime. If one misses in ENV, the variable will default to false instead of crashing (thanks to fetch accepting a default value).

So our test does not really evaluate the presence and setting of our feature flags. It only tests if the return value is truthy or falsy. And it is, since fetch defaults to false every time. We could mock the value ENV returns, but it’ll couple our tests too tightly to our testing environment. This smells like a bad idea.

Our tests could be better if we changed the expectation from .to be_in([true, false]) to a more constrained .to be(true). This would make sure that the tests don’t return a false positive.

Another thing I don’t like is passing variables - which are variable by definition - to a constant? Meh.

I’d rather group these variables into a dedicated file. If we could load all these feature flags’ variables at boot time, we’d save up some time when our application needs to evaluate those flags.

Move your environment variables to a single file and load them at boot time

One way to group your feature flags in the standard Rails configuration. And it’s simpler than it sounds.

You can create a custom accessor in your Rails configuration that leans on a single YAML file.

First, go to your application.rb. At the end of the Application class, add your own configuration.

  # in config/application.rb
  module MyApp
  class Application < Rails::Application
    # ...

    config.features = config_for(:features)
  end
end

What’s happening here? Now, your Rails::Application::Configuration class has a features accessor that you can call with Rails.config.features.

Rails.config.features will return an ActiveSupport::OrderedOptions that inherits from Hash and provides a dynamic accessor method.

Your application will now look for a YAML file named features.yml.

In your config directory, create a features.yml file. The first key represents the environment where your variables will apply. In our example, I’ve used shared which means that all environments will share the information.

  # in config/features.yml
  shared:
    audiobooks: <%= ENV.fetch('AUDIOBOOKS_FEATURE', false) %>
    comic_strips: <%= ENV.fetch('COMIC_STRIPS_FEATURE', false) %>
    editorial_feed: <%= ENV.fetch('EDITORIAL_FEED_FEATURE', false) %>

We usually store strings in YAML files, but it’s possible to execute Ruby code with the help of the <%= => syntax.

Now, we have a single file where all our feature flags and their respective environment variables will be neatly gathered. I used to be a librarian, I love things arranged neatly.

Access our feature flags through Rails configuration

Now, a single file centralize all our feature flag keys and their respective environment variables. I used to be a librarian. I love things arranged neatly.

It’s time to get back to our Features class.

We don’t need our constant anymore. But we need to pull the configuration in its place.

Let’s whip up a second test.

  RSpec.describe Features do
    describe '.configuration' do
      subject { described_class.configuration }

      it { is_expected.to be_an_instance_of(ActiveSupport::OrderedOptions) }
    end
  end

Let’s try to code our method and make the test green.

  class Features
    def self.configuration
      Rails.configuration.features
    end

    # ...
  end

Easy right! The test passes. On the other hand, we’re creating a dependency between our Features class and the Rails configuration.

Now that we’ve made the change easy, it’s just a matter of changing FEATURES by configuration in our enabled? method.

  class Features
    def self.configuration
      Rails.configuration.features
    end

    def self.enabled?(feature)
      configuration[feature.to_sym]
    end
  end

The full test suite is:

  RSpec.describe Features do
    describe '.configuration' do
      subject { described_class.configuration }

      it { is_expected.to be_an_instance_of(ActiveSupport::OrderedOptions) }
    end

    describe '.enabled?' do
      subject { described_class.enabled?(feature) }

      let(:feature) { :my_feature }

      it { is_expected.to be(true) }
    end
  end

Okay, so why does my second test throw a failure?

Well, we created a dependency between Features.configuration and the Rails configuration. And that dependency needs to be mocked!

  RSpec.describe Features do
    describe '.configuration' do
      subject { described_class.configuration }

      it { is_expected.to be_an_instance_of(ActiveSupport::OrderedOptions) }
    end

    describe '.enabled?' do
      subject { described_class.enabled?(feature) }

      let(:feature) { :my_feature }

      before do
        allow(Rails.configuration).to receive(:features).and_return({ my_feature: true})
      end

      it { is_expected.to be(true) }
    end
  end

This’ll take care of the dependency. Now, my tests pass.

Check for different return scenarii

What happens if the feature we’d like to check is not in our list? configuration[feature.to_sym] will return nil. Let’s update our tests accordingly.

  RSpec.describe Features do
    describe '.configuration' do
      subject { described_class.configuration }

      it { is_expected.to be_an_instance_of(ActiveSupport::OrderedOptions) }
    end

    describe '.enabled?' do
      subject { described_class.enabled?(feature) }

      let(:feature) { :my_feature }

      before do
        allow(Rails.configuration).to receive(:features).and_return({ my_feature: true} )
      end

      it { is_expected.to be(true) }

      context 'with a feature not in the features list' do
        let(:feature) { :an_inexistant_feature }

        it { is_expected.to be(nil) }
      end
    end
  end

My third test passes. But by convention, enabled? should return a boolean, not nil. So we need to enforce its return type.

Enforce a boolean return type

First, let’s update our tests.

  RSpec.describe Features do
    describe '.configuration' do
      subject { described_class.configuration }

      it { is_expected.to be_an_instance_of(ActiveSupport::OrderedOptions) }
    end

    describe '.enabled?' do
      subject { described_class.enabled?(feature) }

      let(:feature) { :my_feature }

      before do
        allow(Rails.configuration).to receive(:features).and_return({ my_feature: true} )
      end

      it { is_expected.to be(true) }

      context 'when the symbol is not present is our list of feature flags' do
        let(:feature) { :an_inexistent_feature }

        it { is_expected.to be(true) }
      end
    end
  end

There are several ways of doing this. Initially, I’d decided to modify TrueClass, FalseClass, and NilClass to accept a to_boolean method. Several readers pointed out it was a tad overkill.

Use hashes capabilities

Since .configuration return an object inheriting from Hash, we can use .fetch on it.

  class Features
    def self.configuration
      Rails.configuration.features
    end

    def self.enabled?(feature)
      configuration.fetch(feature.to_sym, false)
    end
  end

What happens is if our .configuration contains feature we’re passing along, .enabled? will return the boolean stored in the YAML. If feature is not present, .fetch will default to false.

Use .present?

Another suggestion was using .present? on my configuration hash.

  class Features
    def self.configuration
      Rails.configuration.features
    end

    def self.enabled?(feature)
      configuration[feature.to_sym].present?
    end
  end

The return results’ strategy works as in the previous example.

Use the double bang

A last suggestion was using !! a.k.a the double bang. This would look like this:

  class Features
    def self.configuration
      Rails.configuration.features
    end

    def self.enabled?(feature)
      !!configuration[feature.to_sym]
    end
  end

The rationale behind it is:

If you negate something, that forces a boolean context. Of course, it also negates it. If you double-negate it, it forces the boolean context, but returns the proper boolean value.

And voilà! Your feature flags manager is ready. Now, you can safely wrap your features with Features.enabled? :editorial_feed conditionals!

Hope you liked this code-along as much as I did!

Cheers,

Rémi - @remi@ruby.social

PS: Many thanks to @NotGrm, @sunfox, @_swanson, and Kaloyan for their suggestions!

PPS: John Nunemaker wrote a Rebuttal and Addendum to this post. John - whose blog I love - makes some great suggestions to better the feature flags manager. I like the idea of switching features during runtime without restarting your dynos. Obviously, as the creator of Flipper - a gem dedicated to flipping features - he’s created something much more comprehensive for people who need more control over their feature toggles.