Vlad Hilko
Posted on July 12, 2023
Overview
In this article, we are going to delve into the area of Feature Flags. We will explain what a feature flag is, why we need it, and how we can use it. We will discuss how to create feature flags and explore the advantages and disadvantages they offer. Additionally, we will explore different feature release strategies that can be safely employed in your Rails application. Finally, we will consider various improvements that can be applied to enhance the reliability of feature flags.
Definition
In simple terms, a feature flag is a technique used in software development to enable or disable specific features at runtime. Feature flags act as switches that control the availability and visibility of certain features within an application without the need for redeploying or modifying the code. Using feature flags has its pros and cons, let's consider them:
Advantages:
- Risk Mitigation
Easily disable features in case of unexpected problems or errors.
- A/B Testing and Experimentation
Conduct experiments and compare different feature variations to make informed decisions.
- Controlled Rollouts
Allows testing the feature on different platforms (staging/sandbox) before enabling it in production.
- Speed of merging
Developers can work on small pull requests that are merged frequently into the main code branch.
Problems:
- Increased Complexity
Increases code loading and cognitive load due to the need for additional conditional checks in the code.
- Technical Debt
If feature flags are not appropriately managed and cleaned up, they can become deprecated or unnecessary, resulting in increased code complexity and maintenance overhead.
- Testing Overhead
The presence of multiple feature variations requires thorough testing of each flag's behavior and interactions, potentially increasing the testing effort and complexity.
It's important to remember that a feature flag is a temporary solution that should be removed after the feature is fully implemented, tested, delivered, and approved by clients.
Implementation
We're going to use the flipper gem. Let's take a look at how to use it properly.
Installation
First of all, we need to install this gem.
# Gemfile
gem 'flipper'
And then execute:
bundle
The gem provides interfaces for the following most important features:
- Enabling/Disabling the feature
- Checking if the feature is enabled or not
- Showing the list of all features
# rails c
Flipper.features
# #<Set: {}>
Flipper.enable :search
# #<Set: {#<Flipper::Feature:72040 name=:search, state=:on, enabled_gate_names=[:boolean], adapter=:memoizable>}>
Flipper.enabled? :search
# true
Flipper.disable :search
# #<Set: {#<Flipper::Feature:72040 name=:search, state=:off, enabled_gate_names=[], adapter=:memoizable>}>
Flipper.enabled? :search
# false
The problem with the current approach is that the data stored in memory will be lost after reloading the Rails console. Therefore, we need to determine where exactly we want to store this data. Flipper provides several available options for storing the value, called adapters. In this case, we're going to use flipper-active_record as it is the most familiar one.
Add ActiveRecord adapter
To use the ActiveRecord adapter, we need to install the gem:
# Gemfile
gem 'flipper-active_record'
And then execute:
bundle
After the gem is installed, we need to generate a migration where our data will be stored:
rails g flipper:active_record
And then execute:
rails db:migrate
Let's see how it works in the Rails console:
# rails c
Flipper.features
# #<Set: {}>
Flipper.enable :search
# This command creates the following records in the database:
# [
# #<Flipper::Adapters::ActiveRecord::Feature:0x000000011672da78
# id: 1,
# key: "search",
# created_at: Sun, 09 Jul 2023 13:08:14.499774000 UTC +00:00,
# updated_at: Sun, 09 Jul 2023 13:08:14.499774000 UTC +00:00>
# ]
# [
# #<Flipper::Adapters::ActiveRecord::Gate:0x00000001166ddbb8
# id: 1,
# feature_key: "search",
# key: "boolean",
# value: "true",
# created_at: Sun, 09 Jul 2023 13:08:14.527445000 UTC +00:00,
# updated_at: Sun, 09 Jul 2023 13:08:14.527445000 UTC +00:00>
# ]
Flipper.enabled? :search
# true
That's it.
Feature Release Strategies
Feature release strategies refer to the approaches and techniques used to release new features or updates in software development.
Here are some common feature release strategies:
- Big Bang Release
- Incremental Release
- Feature Flags/Toggles
- Canary Release
- Phased Rollout
- Fault tolerance
We will discuss these strategies using a simple abstract example of changing the background color of our website to red. Let's take a closer look at each of them and provide examples of their implementation in Ruby.
Big Bang Release
The Big Bang Release strategy states that all changes need to be deployed at once as a single, comprehensive update. This strategy does not require the use of feature flags. For example, if you had the following code in your application:
# change_background_color.rb
ChangeBackgroundColor.call('blue')
The Big Bang Strategy will replace this code all at once wherever it is used, in a single move
# change_background_color.rb
ChangeBackgroundColor.call('red')
Incremental Release
The Incremental Release strategy states that you should deliver your code piece by piece to catch possible errors as soon as possible. For example, if we have the following code:
# change_background_color_1.rb
ChangeBackgroundColor1.call('blue')
and
# change_background_color_2.rb
ChangeBackgroundColor2.call('blue')
The Incremental Release Strategy suggests updating and deploying the first file first, and only after that, update and deploy the second one. For example, in the first deployment, we update the first file as follows:
# change_background_color_1.rb
ChangeBackgroundColor1.call('red')
Then, in the second deployment, we update the second file as follows:
# change_background_color_2.rb
ChangeBackgroundColor2.call('red')
So, when we have multiple changes, they should be split and gradually updated and deployed.
Feature Flags/Toggles
This is the first strategy where Feature Flags fit perfectly. In this strategy, we choose the behavior depending on whether the feature flag is enabled or not:
# change_background_color.rb
if Flipper.enabled? :red_background_color
ChangeBackgroundColor.call('red')
else
ChangeBackgroundColor.call('blue')
end
It allows us to disable the feature if something goes wrong.
Canary Release
The Canary Release Strategy allows us to enable a feature for specific percentages of users. This enables us to perform slow rollouts. For example, if we want to enable the feature only for one user, we can do the following:
user = User.first
Flipper.enabled?(:red_background_color)
# => false
Flipper.enabled?(:red_background_color, user)
# => false
Flipper.enable(:red_background_color, user)
Flipper.enabled?(:red_background_color)
# => false
Flipper.enabled?(:red_background_color, user)
# => true
But if we want to enable the :red_background_color
feature for only 25% of users, we need to do the following:
Flipper.enable_percentage_of_actors(:red_background_color, 25)
user = User.first
Flipper.enabled?(:red_background_color, user)
# => true/false
Please note that you can read more about how the algorithm actually works here.
Phased Rollout
The Phased Rollout Strategy allows us to enable a feature for specific groups, such as by country, role, or status etc. To enable this feature, we need to define the criteria or conditions for determining when the enabling should occur:
user = User.first
# => <User id: 1, role: "admin">
Flipper.register(:admins) do |actor, context|
actor.respond_to?(:role) && actor.role == 'admin'
end
Flipper.enable_group(:red_background_color, :admins)
Flipper.enabled?(:red_background_color, user)
# true
Let's consider another example where we want to enable the feature for all users from the USA. It would look like this:
user = User.first
# => <User id: 1, country: "USA">
Flipper.register(:from_usa) do |actor, context|
actor.respond_to?(:country) && actor.country == 'USA'
end
Flipper.enable_group(:red_background_color, :from_usa)
Flipper.enabled?(:red_background_color, user)
# => true
However, we have one problem. Where should we store Flipper.register(:admins)
? Since we don't save the value of the block in the database, this block should be placed somewhere at the configuration level. Let's add it to the initializer:
# config/initializers/flipper.rb
# frozen_string_literal: true
require 'flipper'
Rails.application.reloader.to_prepare do
Flipper.register(:admins) do |actor, context|
actor.respond_to?(:role) && actor.role == 'admin'
end
end
That's it.
Fault Tolerance
In this strategy, we run the new version of the code, but if something unexpected happens, we fallback to the previous version. It can be implemented as follows:
begin
ChangeBackgroundColor.call('red')
rescue UnexpectedError
ChangeBackgroundColor.call('blue')
end
Improvements
Our solution is not perfect; there is still some room for improvement. Let's explore how we can enhance the following aspects:
- Removing the feature flag
- Optimizing requests using caching
- Providing a user interface (UI)
- Adding an API
- Adding validations for Feature Flags
- Including information about the Feature Flags
- Improving Spec Performance
Removing the feature flag
It's important to remember that every feature flag is a temporary solution for safe rollouts, and sooner or later they should be removed from the codebase. How can we do it? To remove a Feature Flag, we need to follow these steps:
Flipper.remove(:red_background_color)
# => true
Optimizing requests using caching
To optimize the retrieval of feature flag information and avoid unnecessary database calls, Flipper provides the option to enable caching. To implement caching, you need to install the flipper-active_support_cache_store
gem by adding it to your Gemfile:
# Gemfile
gem 'flipper-active_support_cache_store'
After adding the gem, you need to update the configuration file as follows:
# config/initializers/flipper.rb
# frozen_string_literal: true
require 'flipper/adapters/active_record'
require 'flipper/adapters/active_support_cache_store'
Rails.application.reloader.to_prepare do
Flipper.configure do |config|
config.adapter do
Flipper::Adapters::ActiveSupportCacheStore.new(
Flipper::Adapters::ActiveRecord.new,
ActiveSupport::Cache::MemoryStore.new,
expires_in: 5.minutes
)
end
end
end
With this configuration in place, you can now check if a feature is enabled without triggering a database call for the next 5 minutes:
# rails console
Flipper.enabled?(:red_background_color)
# triggers a database call
Flipper.enabled?(:red_background_color)
# does not trigger a database call
Providing a user interface (UI)
To add a user interface (UI) for managing feature flags, you can follow these steps:
- Add the
flipper-ui
gem to your application's Gemfile:
# Gemfile
gem 'flipper-ui'
- Execute the bundle command to install the gem:
bundle
- Update your config/routes.rb file to mount the Flipper UI:
# config/routes.rb
YourRailsApp::Application.routes.draw do
mount Flipper::UI.app(Flipper) => '/flipper'
end
After updating the routes, the new UI will be available at http://localhost:3000/flipper (assuming your application is running on localhost and port 3000). You can find more information about using the Flipper UI here.
Adding an API
What if you want to expose the list of feature flags as an API? This can be useful when your client and API are separated, making it easier to retrieve the data. To add the Flipper API to your project, follow these steps:
First, install the flipper-api
gem by adding it to your Gemfile:
# Gemfile
gem 'flipper-api'
Next, execute the bundle
command to install the gem:
bundle
Update your config/routes.rb
file to include the Flipper API:
# config/routes.rb
YourRailsApp::Application.routes.draw do
mount Flipper::Api.app(Flipper) => '/flipper/api'
end
With this configuration, you can make a GET request to http://localhost:3000/flipper/api/features to retrieve the list of feature flags. The response will be in JSON format, as shown below:
{
"features":[
{
"key":"red_background_color",
"state":"on",
"gates":[
{
"key":"boolean",
"value":"true",
"name":"boolean"
},
{
"key":"actors",
"value":[
],
"name":"actor"
},
{
"key":"percentage_of_actors",
"value":null,
"name":"percentage_of_actors"
},
{
"key":"percentage_of_time",
"value":null,
"name":"percentage_of_time"
},
{
"key":"groups",
"value":[
],
"name":"group"
}
]
}
]
}
For more details on adding an API, refer to the Flipper documentation here
Adding validations for Feature Flags
As you may have noticed, our current implementation allows adding flags with any names and removing flags without restrictions. However, this approach is not reliable, as someone could make a typo and mistakenly enable/disable the wrong flag or accidentally remove a flag that is actively being used in production. Let's explore a possible solution to prevent such issues.
First, we can create a value object to keep track of all available feature flags. This will help us ensure that only valid flags are used. Let's create the FeatureFlag value object:
# app/value_objects/feature_flag.rb
# frozen_string_literal: true
class FeatureFlag
class << self
def all
[
'search',
'red_background_color'
]
end
def supported?(flag_name)
all.include?(flag_name.to_s)
end
end
end
This value object provides a list of all available feature flags and allows us to check if a specific flag is supported:
FeatureFlag.all
# => ['search', 'red_background_color']
FeatureFlag.supported?(:red_background_color)
# => true
Next, we can create a custom adapter that will enforce the availability of feature flags before enabling or adding them. The adapter can be implemented as follows:
# lib/feature_flags/adapters/active_record_based.rb
# frozen_string_literal: true
require 'flipper'
module FeatureFlags
module Adapters
class ActiveRecordBased < Flipper::Adapters::ActiveRecord
def add(feature)
return false unless supported_feature_flag?(feature.name)
super
end
def enable(feature, gate, thing)
return false unless supported_feature_flag?(feature.name)
super
end
def remove(feature)
return false if supported_feature_flag?(feature.name)
super
end
private
def supported_feature_flag?(feature)
FeatureFlag.supported?(feature)
end
end
end
end
In this adapter, we check if the given feature flag is supported before performing actions such as adding, enabling, or removing it.
To include this custom adapter in the Flipper configuration, we can update the initializer file:
# config/initializers/flipper.rb
# frozen_string_literal: true
require 'flipper/adapters/active_record'
Rails.application.reloader.to_prepare do
Flipper.configure do |config|
config.adapter { FeatureFlags::Adapters::ActiveRecordBased.new }
end
end
Now, with these validations in place, the restrictions prevent performing actions with unsupported flags:
Flipper.enable(:not_supported)
# => false
Flipper.enable(:red_background_color)
# => true
Flipper.remove(:red_background_color)
# => false
Including information about the Feature Flags
As we mentioned earlier, feature flags should eventually be removed. It would be beneficial to have more information about each flag beyond just the name. Let's add additional fields to the feature flag value object, such as:
- Description
- Expected expiration date
- Owner
To achieve this, we can update the FeatureFlag value object:
# frozen_string_literal: true
class FeatureFlag
class << self
def all
[
{
name: 'search',
description: 'Search description',
expected_expiration_date: 2024-01-01,
owner: 'Backend team'
},
{
name: 'red_background_color',
description: 'Red background color description',
expected_expiration_date: 2024-01-02,
owner: 'Frontend team'
}
]
end
def supported?(flag_name)
all.map { _1[:name] }.include?(flag_name.to_s)
end
end
end
Now, each feature flag includes additional information such as description, expected expiration date, and owner.
Note: You can also consider defining constants in a YAML file to simplify the interface and keep the information organized.
Improving Spec Performance
To improve the performance of your tests, it is recommended to avoid unnecessary database hits when working with feature flags. You can achieve this by replacing the Active Record adapter with the Memory Adapter for your test environment.
Here's what you need to do:
Update your config/initializers/flipper.rb file
as follows:
# config/initializers/flipper.rb
# frozen_string_literal: true
require 'flipper/adapters/active_record'
Rails.application.reloader.to_prepare do
Flipper.configure do |config|
config.adapter do
Rails.env.test? ? Flipper::Adapters::Memory.new : Flipper::Adapters::ActiveRecord.new
end
end
end
With this configuration, the Memory Adapter will be used for the test environment, while the Active Record Adapter will be used for other environments.
Now, when you run rails console in the test environment (rails c -e test
), you can verify that the database is not being triggered when working with feature flags:
# rails c -e t
Flipper.enable(:red_background_color)
Flipper.enable?(:red_background_color)
This change will help improve the performance of your test suite by eliminating unnecessary database hits.
Conclusion
In this article, we've been diving deep into the world of feature flags. We explored the benefits of using feature flags, including risk mitigation, A/B testing, controlled rollouts, and faster development cycles.
We discussed different feature release strategies, such as the Big Bang Release, Incremental Release, Feature Flags/Toggles, Canary Release, Phased Rollout, and Fault Tolerance. Each strategy offers a unique approach to releasing new features or updates, providing flexibility and control over the rollout process.
To enhance our feature flag implementation, we made several improvements. We added validations to ensure that only supported flags can be enabled or added, preventing potential errors. We integrated caching to optimize performance by reducing unnecessary database hits. Additionally, we explored the options of providing a user interface and adding an API to expose feature flag information to clients.
Posted on July 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024