Feature flags are an effective method for developing and introducing large changes to an existing codebase without large interruptions to the engineering team. We have over 400 ERB files (views & partials) in our Rails app, and we re-wrote all of them over the course of 9 months using feature flags.

Some large projects can be thought of as enhancements, where you start with a set of base functionality and then gradually enhance the user’s experience, but other projects only work in their entirety. In our case, entirely revamping our UI was the latter. It would have provided a poor user experience to have drastic styling and layout changes as you click from one page to the next. We wanted the user either fully in the old UI system (V1), or fully in the new UI system (V2).

The launch of V2 was anti-climactic, boring even, but most importantly, stress free. This is in large part due to how we approach the development and rollout of projects at Wrapbook. As a playbook for this development approach, we’ll cover the following 5 steps:

  1. Introduce feature flags early
  2. Ship small PRs
  3. Test both code paths
  4. Control rollout
  5. Clean up

Why re-write the entire UI?

Why was this re-write necessary in the first place? V1 of our UI was written with UIKit, which is a fantastic, lightweight framework for developing web interfaces. It served us well for many years, but our developers were finding that we often spent more time trying to customize the design and interface to work within UIKit than it would have taken to build it outside of a framework, using modern CSS. It was for this reason that we decided to re-write our styles and views from the ground up.

Introduce feature flags early

Feature flags provide a control mechanism for whether code should be executed or not. This allows code to exist in your codebase without it actually being used. There is typically a way to control whether it is enabled for specific actors, a percentage of actors, or all actors. We use flipper to manage our feature flags at Wrapbook, which comes with a UI to manage them, a way to persist them (ie. Redis or ActiveRecord), and an easy way to check whether you should run a specific code path.

if Flipper.enabled?(:new_ui, current_user)
  # show new UI
else
  # show old UI
end

We decided to control which UI version is displayed in the controller, taking advantage of the render method inside of a controller’s action by adding a suffix of _v2 to the view.

class CompaniesController < ApplicationController
  def new
    render "new#{template_suffix}"
  end
end

This means that we worked with two versions of each of our views:

_form.html.erb
_form_v2.html.erb
edit.html.erb
edit_v2.html.erb
index.html.erb
index_v2.html.erb
new.html.erb
new_v2.html.erb

The template_suffix method was defined in a concern that was included in our ApplicationController. It also included the ability to override whether the new UI was enabled by passing a query parameter for development purposes. By approaching file naming and the instruction of the V2 templates this way, it allows us to incrementally implement V2 files without having to duplicate the V1 (original) template.

module NewUiConcern

  extend ActiveSupport::Concern

  included do
    helper_method :template_suffix
    helper_method :new_ui?

  private

    def template_suffix
      new_ui? ? "_v2" : ""
    end

    def new_ui?
      params[:new_ui].to_boolean || Flipper.enabled?(:new_ui, current_user)
    end
  end

end

Ship small PRs

The goal of introducing a feature flag early is to allow us to ship small PRs. Because no users are using the new UI, we don’t have to wait until the entire project is ready to launch. We can slowly get the code in place, which allows us to minimize the cognitive load on our PR reviewers, prevent a big bang deploy, and reduce the pain of keeping a long-running PR up to date with the main branch (and all of the merge conflicts that come along with it).

You can imagine the size of this PR if we waited until all 400 views & partials were ready to go before merging all of them.

Test both code paths

Just like testing both branches of an if statement, we should also aim to test our code with the feature flag enabled and disabled. In our case we could either do this by passing a new_ui query param, or by controlling the flow with Flipper. Below are two examples of controller tests that verify that each version of the template is rendered correctly.

RSpec.describe Company::ProjectsController, type: :controller do
  let(:company) { companies(:default) }
  let(:user)    { users(:company_admin) }

  it "renders page" do
    login_as(user)
    allow(Flipper).to receive(:enabled?).with(:new_ui, user).and_return(false)

    get :index, params: { company_id: company }

    expect(response).to be_successful
    expect(response).to render_template(:index)
  end

  it "renders page V2" do
    login_as(user)
    allow(Flipper).to receive(:enabled?).with(:new_ui, user).and_return(true)

    get :index, params: { company_id: company }

    expect(response).to be_successful
    expect(response).to render_template(:index_v2)
  end
end

Testing with all features enabled

Testing in isolation is a great first step, but on production the feature flags don’t operate in isolation. One or more of them may combine in such a way to create unexpected behaviour. We aim to alleviate this risk by running our entire test suite in two ways:

  1. Normal behaviour
  2. Force all feature flags to be enabled

Our CI runs within GitHub Actions, and we take advantage of the build matrix functionality to run our tests once with an environment variable ENABLE_ALL_FLAGS set to false, and again with ENABLE_ALL_FLAGS set to true. Because they run in parallel it doesn’t slow down the overall process.

jobs:
  test:
    name: Test Back-End ${{ matrix.enable_all_flags && ' (All Feature Flags)' || ''}}
    strategy:
      matrix:
        enable_all_flags:
        - false
        - true
    steps:
    - name: Setup variables
      env:
        ENABLE_ALL_FLAGS: "${{ matrix.enable_all_flags }}"

Within our code we can override all Flipper.enabled? checks to return true when this ENV variable is turned on.

RSpec.configure do |config|
  config.add_setting :force_enable_all_flags, default: ENV.fetch("ENABLE_ALL_FLAGS", "false").casecmp?("true")

  config.before do
    if RSpec.configuration.force_enable_all_flags?
      # Globally enable all features
      allow(Flipper).to receive(:enabled?).and_return(true)
    end
  end
end

Control rollout

After we had completed the conversion of every page on the website, we began the rollout phase. Rolling out a feature typically follows four steps:

  1. Nobody (fully disabled)
  2. Specific actors (users, companies, etc…)
  3. Percentage of actors (10%, 20%, etc…)
  4. Everyone (fully enabled)

By following this approach we can start by enabling it perhaps for ourselves at Wrapbook, or for specific users we have close relationships with or who have agreed to be in a beta program. Monitoring for errors is important at this stage because this is the first time “live” users are interacting with the code.

Once we are confident that there are no showstoppers, we can begin to roll it out to larger and larger percentages of our users. Monitoring for performance is important at this stage because we are increasing the number of users to the point where there may be a critical impact.

If there are neither errors nor performance issues to fix, we can now enable the feature for all users.

Clean up

At this point we had all of our users using the new UI, so it was time to circle back and clean up the duplicate versions of our views, special rendering logic and tests. The general approach we took was to:

  1. Delete all views without the _v2 suffix.
  2. Remove special rendering logic to append the _v2 suffix.
  3. Rename all files to remove the _v2 suffix.
  4. Remove all tests that aim to ensure _v2 was being rendered correctly.

Conclusion

Development is about making tradeoffs. By approaching large scale projects this way, you are incurring the additional cost of maintaining two systems or code paths in parallel, in exchange for smaller and more easily reviewable PRs. You also get the added safety of rolling out a new feature via a control or toggle, rather than by deploying code. If there is ever an issue, you’re a single click away from returning to the previous version.