BLOG
DIY Feature Flags for Ruby on Rails
A computer with an illustration of feature flags

Juan Aparicio

Engineering Manager

Jan 16, 2023

4 min read

Ruby

Rails

Tools

Recently, while working on a project at GoGrow we encountered a very common issue: ingesting data from various API’s. This is nothing out of the ordinary, and we were able to resolve it by mapping the data into models and storing them locally.

Everything was fine, until we started seeing errors in Bugsnag in the staging environment. This is because we did not implement a safeguard that makes the ingestion run only in the Production environment.

You may have seen something like this in your codebase:


class DataSynchronizer
  def self.ingest_data
    return unless Rails.env.production?
  
    data = ExternalService::Api.fetch_resources
    ...
  end
end

This solution works and is easy to implement: add a single line on top of your method body and you’re good to go. However, after taking a closer look at it, you may come to realize that this implementation, although fast, is not that great. Now, you are adding environment-dependent code to it and may run into hard-to-debug issues in the future.

Enter feature flags

Feature Flags

Feature flags are a way to enable or disable certain features in your application at runtime. This can be useful for a number of reasons:

  • You can use feature flags to test new features with a small group of users before rolling them out to everyone.
  • Feature flags can help you roll back changes quickly if something goes wrong.
  • You can use feature flags to customize the experience of groups of users.

Although there are many different implementations out there already (see the great flipper gem), they don’t always adapt 100% to your situation. This is why we decided to go with our own approach.

The requirements were the following:

  1. Have an easy way for the dev to toggle features on/off without doing any commits to the code.
  2. Be able to have a default logic for the feature flags but be able to override them in different environments.
  3. Have an easy way to test the features, with separate contexts for the possible states.
  4. We are not taking users/audiences into consideration for now, but we may want to add this capability in the future.

The solution we came up with was to have a default logic for the feature flags, but be able to override them with environment variables.

After thinking about this for a while, this is the interface we came up with:


# Create a feature flag with a name and a block which
# is evaluated each time the flag is checked.
Feature.create_feature :feature_name { some_ruby_code }

# Check if a feature flag is enabled
Feature.feature_name?

# check if a feature flag is enabled
Feature.enabled?(:feature_name)

# override the feature logic so that it is always enabled
Feature.feature_name = true

# override the feature logic so that it is never enabled
Feature.feature_name = false

# unset the feature override
Feature.feature_name = nil

We knew that to keep it dry, we would have to use a bit of metaprogramming to define singleton methods with the same name as the feature flag. Having these methods would help make code clearer and easier to understand.

This is the implementation


# app/services/feature.rb

# frozen_string_literal: true

class Feature
  class MissingFeatureError < StandardError; end
  OVERRIDE_ENV_PREFIX = 'ENABLE_'

  class << self
    def enabled?(feature_name)
      return send(feature_name) if feature_defined?(feature_name)

      raise MissingFeatureError, "Feature #{feature_name} is not defined"
    end

    def enable(feature_name)
      send("#{feature_name}=", true) if feature_defined?(feature_name)
    end

    def disable(feature_name)
      send("#{feature_name}=", false) if feature_defined?(feature_name)
    end

    def unset(feature_name)
      send("#{feature_name}=", nil) if feature_defined?(feature_name)
    end

    def override?(feature_name)
      raise MissingFeatureError, "Feature #{feature_name} is not defined" unless feature_defined?(feature_name)

      env_name = "#{OVERRIDE_ENV_PREFIX}#{feature_name}".upcase
      res = ENV.fetch(env_name, nil)

      if res.is_a?(String)
        %w[true yes 1].include?(res.downcase)
      else
        res
      end
    end

    def feature_defined?(feature_name)
      return false if %i[enabled? override? create_feature? feature_defined?].include?(feature_name)

      respond_to?(feature_name)
    end

    def create_feature(feature_name)
      raise ArgumentError, "Feature #{feature_name} already defined" if feature_defined?(feature_name)

      define_singleton_method(feature_name) do
        override_value = override?(feature_name)

        return override_value unless override_value.nil?

        yield
      end

      define_singleton_method("#{feature_name}?") do
        send(feature_name)
      end

      define_singleton_method("#{feature_name}=") do |value|
        override_name = "#{OVERRIDE_ENV_PREFIX}#{feature_name}".upcase

        if value
          ENV[override_name] = 'true'
        elsif value.is_a?(FalseClass)
          ENV[override_name] = 'false'
        else
          ENV.delete(override_name)
        end
      end

      define_singleton_method("override_#{feature_name}?") do
        override?(feature_name)
      end

      feature_name.to_sym
    end
  end

  create_feature :feature_name, -> { Rails.env.production? }
end

Now you can go ahead and create the feature flags you want at the bottom of the class and paste it into the app/services/feature.rb. Overriding features in your environments should be as easy as adding an environment variable:


# makes feature flag return always true. Possible values are true, yes, 1
ENABLE_FEATURE_NAME = true

# set the value to anything else to disable. If the ENV variable is not present,
# it will execute the code block passed to the create_feature method
ENABLE_FEATURE_NAME = false 

Use it in your code wherever with:


def call
  return unless Feature.feature_name?

  ...
end

To override feature flags in your specs, simply add this to your specs:


before { Feature.feature_name = true|false|nil }

That’s it! With this you are now ready to remove that pesky return unless Rails.env.production? guard from your methods.

Juan Aparicio

Engineering Manager

Jan 16, 2023

4 min read

Ruby

Rails

Tools