Now that we know how feature flags can help us release faster, it’s time to dive into the actual implementation details. How can we easily define feature flags? How to configure them both locally as remotely? And use them in our testing?
This post will present a simple, powerful architecture to manage feature flags and comes with a full example on Github.
Creating new feature flags#
As discussed in part 1, the easier it is to add feature flags, the more likely developers will use the system. At its core a Feature
is something very simple:
interface Feature {
val key: String
val title: String
val explanation: String
val defaultValue: Boolean
}
It needs to have a unique key
to reference it on your remote feature flagging tool. A title
and description
to help understand what it is all about. And optionally a default value, which is handy if you’re using both FeatureFlags
and TestSettings
.
This interface can now be implemented by both a FeatureFlag
and TestSetting
enum:
enum class FeatureFlag(
override val key: String,
override val title: String,
override val explanation: String,
override val defaultValue: Boolean = true
) : Feature {
DARK_MODE("feature.darkmode", "Dark theme", "Enabled dark mode")
}
enum class TestSetting(
override val key: String,
override val title: String,
override val explanation: String,
override val defaultValue: Boolean = false
) : Feature {
USE_DEVELOP_PORTAL("testsetting.usedevelopportal", "Development portal", "Use developer REST endpoint"),
DEBUG_LOGGING("testsetting.debuglogging", "Enable logging", "Print all app logging to console", defaultValue = true)
}
Note how a FeatureFlag
is on by default so that it is immediately visible in developer builds, whereas TestSettings
are off by default as they usually put the app in a specific condition to help with testing.
Both FeatureFlag
and TestSetting
are enums so that the Kotlin compiler can force you to handle each case explicitly in a when
statement. At the same time, they are not sealed classes, because we need to be able to enumerate all items, later on, to automatically generate a UI from it. (There is no way to ask a sealed class to list all it’s subclasses)
Mission accomplished: adding a new
FeatureFlag
/TestSetting
is as easy as adding a one-liner!
AWESOME("feature.awesome", "Awesome", "Does something awesome")
Consuming feature flags#
Next, our app needs to be able to read out what value (true/false) a Feature
is currently set to. This can be done by requesting one of the FeatureFlagProviders
for the current value:
interface FeatureFlagProvider {
val priority: Int
fun isFeatureEnabled(feature: Feature): Boolean
fun hasFeature(feature: Feature): Boolean
}
This interface will have several implementations with different priorities attached to it so that they can override each other. (more on that later)
Note how implementations don’t need to provide a value for every Feature
thanks to the hasFeature()
method! This has two benefits:
- you can prevent accidentally relying on build-in defaults of the feature flag tool you are using by requiring an explicit opt-in for that tool (e.g. Firebase remote config returns false when it doesn’t have a value)
- you can have a chain of providers (e.g. we can have a feature flag that is only locally available, not remotely).
The RuntimeBehavior
links all FeatureFlagProviders
together and exposes the API that should be used from within the application:
object RuntimeBehavior {
@VisibleForTesting
internal val providers = CopyOnWriteArrayList<FeatureFlagProvider>()
@JvmStatic
fun isFeatureEnabled(feature: Feature): Boolean {
return providers.filter { it.hasFeature(feature) }
.sortedBy(FeatureFlagProvider::priority)
.firstOrNull()
?.isFeatureEnabled(feature)
?: feature.defaultValue
}
@JvmStatic
fun addProvider(provider: FeatureFlagProvider) = providers.add(provider)
}
Note how it takes all FeatureFlagProviders
, removes those that don’t provide a value for the Feature
and then takes the value of the highest priority provider. If no one provides a value, the default value is returned.
Thanks to all of this we can now call from anywhere in the app:
if (RuntimeBehavior.isFeatureEnabled(FeatureFlag.DARK_MODE)) {
// set dark theme
} else {
// set light them
}
Consuming
FeatureFlag
/TestSetting
is as easy as asking theRuntimeBehavior
if (isFeatureEnabled(AWESOME)) {
// code here
}
Providing feature flag values#
Let’s have a look at the several different FeatureFlagProviders
, why we need them and how they work.
RuntimeFeatureFlagProvider#
This provider only exists in the debug version of the app and allows to dynamically turn features on or off.
It does this by keeping a SharedPreferences
internally where it automatically stores a value for each Feature
using its key.
class RuntimeFeatureFlagProvider : FeatureFlagProvider {
private val preferences: SharedPreferences
override val priority = MEDIUM_PRIORITY
constructor(applicationContext: Context) {
preferences = applicationContext.getSharedPreferences("runtime.featureflags", Context.MODE_PRIVATE)
}
override fun isFeatureEnabled(feature: Feature): Boolean =
preferences.getBoolean(feature.key, feature.defaultValue)
override fun hasFeature(feature: Feature): Boolean = true
fun setFeatureEnabled(feature: Feature, enabled: Boolean) =
preferences.edit().putBoolean(feature.key, enabled).apply()
}
Notice how this provider has a public API setFeatureEnabled
to change the current value of a Feature
and how every Feature
is always configurable at runtime. (i.e. hasFeature()
returns true for every feature)
The
RuntimeFeatureFlagProvider
allows to locally turn anyFeatureFlag
/TestSetting
on or off.
StoreFeatureFlagProvider#
This provider only exists in the release version of the app and defines the baseline of what Features
are on or off. TestSettings
aren’t exposed in the release version of an app and are always off.
class StoreFeatureFlagProvider : FeatureFlagProvider {
override val priority = MIN_PRIORITY
@Suppress("ComplexMethod")
override fun isFeatureEnabled(feature: Feature): Boolean {
if (feature is FeatureFlag) {
// No "else" branch here -> choosing the default
// option for release must be an explicit choice
return when (feature) {
DARK_MODE -> false
}
} else {
// TestSettings should never be shipped to users
return when (feature as TestSetting) {
else -> false
}
}
}
override fun hasFeature(feature: Feature): Boolean = true
}
Notice how you must provide an explicit value for every feature toggle! This is because you never want to accidentally ship an unfinished feature to users. Non gradual rollout of a feature requires an explicit change to the StoreFeatureFlagProvider
.
Finally, this makes it very easy to check what features are on or off in any given app release. And since all of this is just Kotlin code, it’s easy to write a script to generate a release report with what feature toggles exist and their value for that app version.
The
StoreFeatureFlagProvider
defines for everyFeature
whether it is on or off in the release build
FirebaseFeatureFlagProvider#
One of the most interesting things about FeatureFlags
is that you can gradually roll them out using a remote feature flagging tool. We’ll look at Firebase Remote Config as an example, but this architecture supports any tool.
class FirebaseFeatureFlagProvider(private val isDevModeEnabled: Boolean) : FeatureFlagProvider {
private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
init {
val configSettings = FirebaseRemoteConfigSettings.Builder().setDeveloperModeEnabled(isDevModeEnabled).build()
remoteConfig.setConfigSettings(configSettings)
}
override val priority: Int = MAX_PRIORITY
override fun isFeatureEnabled(feature: Feature): Boolean =
remoteConfig.getBoolean(feature.key)
override fun hasFeature(feature: Feature): Boolean {
return when (feature) {
FeatureFlag.DARK_MODE -> true
else -> false
}
}
}
The most important thing to note here is that the FirebaseFeatureFlagProvider
has the maximum priority, which means it takes precedence over any other FeatureFlagProvider
.
However, it doesn’t provide a value for all feature flags! That is because a FeatureFlag
should only be remotely toggled once development for the feature is done. We don’t want anyone accidentally expose an unfinished/broken feature to users from the feature flag tool console.
Typically the lifecycle of a FeatureFlag
is:
- Development started on new feature ->
FeatureFlag
added - While development ongoing ->
FeatureFlag
locally available - Development done
- Either toggle
FeatureFlag
inStoreFeatureFlagProvider
and roll it out to all users at once. (typically if you have a marketing campaign attached to the feature) - Or add the
FeatureFlag
to theFirebaseFeatureFlagProvider
and gradually roll it out
- Either toggle
- Rollout done -> remove
FeatureFlag
and clean up unused code
The
<tool name> - FeatureFlagProvider
allows to gradually roll out finished features to users
How the different providers work together#
Whenever the RuntimeBehavior
is initialized, it will initialize all providers:
object RuntimeBehavior {
...
@JvmStatic
fun initialize(context: Context, isDebugBuild: Boolean) {
if (isDebugBuild) {
val runtimeFeatureFlagProvider = RuntimeFeatureFlagProvider(context)
addProvider(RuntimeFeatureFlagProvider(context))
if (runtimeFeatureFlagProvider.isFeatureEnabled(TestSetting.DEBUG_FIREBASE)) {
addProvider(FirebaseFeatureFlagProvider(true))
}
} else {
addProvider(StoreFeatureFlagProvider())
addProvider(FirebaseFeatureFlagProvider(false))
}
}
}
For debug builds, usually only the RuntimeFeatureFlagProvider
is enabled so feature flags can be toggled from the test settings screen (more on that next).
But you can even enable the FirebaseFeatureFlagProvider
in the debug build. This allows to also easily test the remote feature flag tool.
In release, however, the FeatureFlag
value is taken from Firebase when the FeatureFlag
was made remotely available (using hasFeature()
in FirebaseFeatureFlagProvider
). If not, the value from StoreFeatureFlagProvider
is used.
Showing the flags in a UI#
Within the developer version of our app, we want to be able to both see the status of all Features
and TestSettings
and also toggle each one on or off. Basically, we want to automatically generate a UI like this:
To show all of the Features
, we can simply define a custom RecyclerView
that displays an Array
of Features
.
private class FeatureFlagAdapter<T : Feature>(
val items: Array<T>,
val provider: FeatureFlagProvider,
val checkedListener: Function2<Feature, Boolean, Unit>
) : RecyclerView.Adapter<FeatureFlagViewHolder<T>>() {
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: FeatureFlagViewHolder<T>, position: Int) =
holder.bind(items[position])
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeatureFlagViewHolder<T> {
val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_featureflag, parent, false)
return FeatureFlagViewHolder(itemView, provider, checkedListener)
}
}
Now it becomes clear why FeatureFlags
and TestSettings
had to be enums, because this allows to pass the FeatureFlagAdapter
either FeatureFlag.values()
or TestSetting.values()
and automatically generate the UI for all defined FeatureFlags
or TestSettings
.
Additionally we hand it the RuntimeFeatureFlagProvider
to look up the current values of each Feature
. Finally it needs a listener to respond when an item gets enabled/disabled.
The FeatureFlagViewHolder
simply binds the properties of the Feature
to the view, requests the current value from the FeatureFlagProvider
and connects a listener to the switch:
private class FeatureFlagViewHolder<T : Feature>(
view: View,
val provider: FeatureFlagProvider,
val checkedListener: Function2<Feature, Boolean, Unit>
) : RecyclerView.ViewHolder(view) {
fun bind(feature: T) {
title.text = feature.title
description.text = feature.explanation
switch.isChecked = provider.isFeatureEnabled(feature)
switch.setOnCheckedChangeListener { switch, isChecked ->
if (switch.isPressed) checkedListener.invoke(feature, isChecked) }
}
}
The listener passed into the FeatureFlagAdapter
simply changes the value of the Feature
on the RuntimeFeatureFlagProvider
and shows a SnackBar
to restart the app to ensure the new value is properly applied:
val checkedListener = { feature: Feature, enabled: Boolean ->
runtimeFeatureFlagProvider.setFeatureEnabled(feature, enabled)
requestRestart()
}
private fun requestRestart() {
val msg = "In order for changes to reflect please restart the app via settings"
Snackbar.make(view!!, msg, Snackbar.LENGTH_INDEFINITE)
.setActionTextColor(Color.RED)
.setAction("Force Stop") { exitProcess(-1) }
.show()
}
Now we just wrap all of this into a TestSettingsActivity
with a separate launch icon and make sure this activity isn’t available in release builds. The simplest way to do this is by moving all UI classes to the debug
source folder.
Now we have a very powerful, easy to use UI framework to dynamically configure the behavior of our app! By just adding a single line FeatureFlag
or TestSetting
it instantly shows up in our UI.
Debug builds have a UI to toggle all
FeatureFlags
andTestSettings
on or off that gets fully automatically generated.
Remote feature flags#
Whilst talking about the FirebaseFeatureFlagProvider
before, there is one important aspect that we didn’t cover: how to refresh the local FeatureFlag
cache with new remote values. Some feature flag tools do that for you automatically, but for others (like Firebase Remote Config), you need to trigger that process manually.
A way to make that fit into our architecture is to define an additional interface RemoteFeatureFlagProvider
that every FeatureFlagProvider
for a remote tool should implement.
interface RemoteFeatureFlagProvider {
fun refreshFeatureFlags()
}
Now we just need to expand the FirebaseFeatureFlagProvider
with this interface and implement refreshFeatureFlags
:
class FirebaseFeatureFlagProvider(private val isDevModeEnabled: Boolean) :
FeatureFlagProvider, RemoteFeatureFlagProvider {
...
override fun refreshFeatureFlags() {
remoteConfig.fetch(getCacheExpirationSeconds(isDevModeEnabled)).addOnCompleteListener { task ->
if (task.isSuccessful) {
// After config data is successfully fetched, it must be activated before newly fetched values are returned.
remoteConfig.activateFetched()
}
}
}
private fun getCacheExpirationSeconds(isDevModeEnabled: Boolean): Long =
if (isDevModeEnabled) ONE_SECOND else ONE_HOUR
}
Then we expose a new function on the RuntimeBehavior
to refresh all remote feature flag providers at the same time whenever you want in your app lifecycle.
object RuntimeBehavior {
fun refreshFeatureFlags() {
providers.filter { it is RemoteFeatureFlagProvider }.forEach { (it as RemoteFeatureFlagProvider).refreshFeatureFlags() }
}
}
Since Firebase Remote Config internally throttles refresh requests, it’s safe to call the refreshFeatureFlags
method on every app resume.
The
RemoteFeatureFlagProvider
offers the generic ability to refresh values for all remote feature flagging tools.
Testing feature flags#
During automated unit or espresso tests, you sometimes need to enable/disable particular features. Even that is simple with this architecture, just make a TestFeatureFlagProvider
:
object TestFeatureFlagProvider : FeatureFlagProvider {
private val features = HashMap<Feature, Boolean>()
override val priority = TEST_PRIORITY
override fun isFeatureEnabled(feature: Feature): Boolean = features[feature]!!
override fun hasFeature(feature: Feature): Boolean = features.containsKey(feature)
fun setFeatureEnabled(feature: Feature, enabled: Boolean) = features.put(feature, enabled)
fun clearFeatures() = features.clear()
}
With its TEST_PRIORITY
it takes precedence over all other FeatureFlagProviders
and exposes an API to dynamically enable/disable features and to clear its state after each test.
@Test
fun withFeatureFlags() {
TestFeatureFlagProvider.enableFeature(DARK_MODE)
// do test here
}
@After
fun tearDown() {
TestFeatureFlagProvider.clearFeatures()
}
And this extra provider gets added during the debug initialization in the RuntimeBehavior
:
object RuntimeBehavior {
@VisibleForTesting
internal val providers = CopyOnWriteArrayList<FeatureFlagProvider>()
fun initialize(context: Context, isDebugBuild: Boolean) {
if (isDebugBuild) {
val runtimeFeatureFlagProvider = RuntimeFeatureFlagProvider(context)
addProvider(runtimeFeatureFlagProvider)
addProvider(TestFeatureFlagProvider)
if (runtimeFeatureFlagProvider.isFeatureEnabled(TestSetting.DEBUG_FIREBASE)) {
addProvider(FirebaseFeatureFlagProvider(true))
}
} else {
addProvider(StoreFeatureFlagProvider())
addProvider(FirebaseFeatureFlagProvider(false))
}
}
}
The
TestFeatureFlagProvider
allows to turnFeatureFlags
orTestSettings
on during unit/instrumentation tests.
Putting it all together#
When we look at all of the classes involved we get the following overview:
While that might seem a bit overwhelming, it consists of a lot of very small classes that are very easy to understand:
RuntimeBehavior
: to easily consume feature flagsFeatureFlagProvider
: to provide values during debug, release or testingFeature
: one-line definition ofFeatureFlags
orTestSettings
And based on these classes, a local UI is automagically generated to toggle the Features
on/off in debug builds!
I’ve created a full Github sample project where you can see all code in action here
Bonus#
When combining this Feature Flag architecture with my previous modularization architecture, all UI classes can be moved to their own feature module test-settings
that is only included into the app
module for debug builds:
dependencies {
debugImplementation project(':features:test-settings')
}
This does require all feature flag business logic to move to a library module feature-flags
, but the end result is a very clean!
Wrap-up#
With just a few simple classes we’ve been able to build a powerful feature flagging architecture. In that it’s very easy to add new features, there is support for both local and remote feature flags, feature flags are testable and a local UI for enabling/disabling feature flags is automatically generated.
If you’ve made it this far you should probably follow me on Mastodon. Feel free leave a comment below!