User guide¶
Features¶
Feature flags are nothing more than enums that implement the Feature
interface. It allows us to define a default option, remote sources that can provide different options and descriptions for some human-readable metadata.
Danger
Feature
enums must have at least one option. Defining an enum like below will make Laboratory
throw an exception when used to read an option.
enum class SomeFeature : Feature<SomeFeature>
Tip
Check the samples to learn by example.
I/O¶
Laboratory
is nothing more than a high-level API over the FeatureStorage
interface responsible for persisting feature flags. All implementations that are provided by this library rely on a feature flag package name and an enum name.
Warning
Because the persistence mechanism relies on package names and enum names, you should be careful when refactoring feature flags already available on production. Changing these options may result in a perception of unsaved feature flags.
Because FeatureStorage
is an interface that is meant to be used with I/O operations, it exposes only suspend
functions. Laboratory
, on the other hand, allows you to opt-into blocking equivalents of read and write functions. You can selectively do this by applying the @BlockingIoCall
annotation or globally by adding a compiler flag.
android {
kotlinOptions {
freeCompilerArgs += [
"-Xopt-in=io.mehow.laboratory.BlockingIoCall",
]
}
}
In either case, a design that relies on non-blocking function calls is preferable.
Sources¶
Feature flags, by default, have only a single source for their options. By convention, it is considered to be a local source. However, you might need to have different data sources for feature flags, depending on some runtime conditions or a build variant. For example, you might want to use a local source during debugging and rely on some remote services on production.
Let’s say that you want to have a feature flag that has three sources. One local, and two remote ones.
Info
Notice that a feature flag source is also a feature flag. This allows us to change feature flag sources via Laboratory
as well.
enum class PowerType : Feature<PowerType> {
Coal,
Wind,
Solar;
public override val defaultOption get() = Solar
@Suppress("UNCHECKED_CAST")
override val source = Source::class.java as Class<Feature<*>>
enum class Source : Feature<Source> {
Local,
Firebase,
Azure;
public override val defaultOption get() = Firebase
}
}
If you define multiple sources for a feature flag, you should add a Local
option to them. This allows changing feature flag options at runtime from the QA module.
This feature flag definition allows configuring Laboratory
in a way that it is capable of recognizing that PowerType
has different option providers and that the default provider is Firebase
.
Because the Laboratory
only delegates its work to FeatureStorage
, it is FeatureStorage
that needs to understand how to connect feature flags with their sources. This is possible with a special implementation of this interface that is available as an extension function.
val sourcedFeatureStorage = FeatureStorage.sourced(
localSource = FeatureStorage.inMemory(),
remoteSources = mapOf(
"Firebase" to FeatureStorage.inMemory(),
"Azure" to FeatureStorage.inMemory(),
),
)
sourcedFeatureStorage
delegates persistence mechanism to three different storage and is responsible for coordinating a selected source and a current feature flag option.
One error-prone thing is that sourcedFeatureStorage
relies on strings and source names to use the correct storage. The reason for this is that two different feature flags might share sources partially.
Tip
Using Gradle plugin allows you to avoid this issue with the generation of a custom FeatureStorage
that is always up-to-date.
enum class PowerType : Feature<PowerType> {
Coal,
Wind,
Solar;
public override val defaultOption get() = Solar
@Suppress("UNCHECKED_CAST")
override val source = Source::class.java as Class<Feature<*>>
enum class Source : Feature<Source> {
Local,
Firebase,
Azure;
public override val defaultOption get() = Firebase
}
}
enum class Theme : Feature<PowerType> {
Night,
Day,
Christmas;
public override val defaultOption get() = Night
@Suppress("UNCHECKED_CAST")
override val source = Source::class.java as Class<Feature<*>>
enum class Source : Feature<Source> {
Local,
Azure;
public override val defaultOption get() = Local
}
}
In this case, Theme
and PowerType
feature flags share Azure
source, but Firebase
applies only to the PowerType
flag.
// Create laboratory that understands sourced features
val laboratory = Laboratory.create(sourcedFeatureStorage)
// Check option of PowerType in Firebase FeatureStorage
val powerTypeFirebaseValue = laboratory.experiment<PowerType>()
// Check option of Theme in local FeatureStorage
val themeLocalValue = laboratory.experiment<Theme>()
// Set source of Theme source to Azure (PowerType is still unaffected and uses Firebase)
val success = laboratory.setOption(Theme.Source.Azure)
// Check option of Theme in Azure FeatureStorage
val themeAzureValue = laboratory.experiment<Theme>()
Info
The implementation of sourcedFeatureStorage
provided by the library saves data only in localSource
.
To propagate remote feature flag options on updates, they need to be connected to a remote source.
enum class ShowAds : Feature<ShowAds> {
Enabled,
Disabled;
public override val defaultOption get() = Disabled
@Suppress("UNCHECKED_CAST")
override val source = Source::class.java as Class<Feature<*>>
enum class Source : Feature<Source> {
Local,
Remote;
public override val defaultOption get() = Remote
}
}
val firebaseStorage = FeatureStorage.inMemory()
val sourcedFeatureStorage = FeatureStorage.sourced(
localSource = FeatureStorage.inMemory(),
remoteSources = mapOf("Remote" to firebaseStorage),
)
// During application initialisation
val laboratory = Laboratory.create(sourcedFeatureStorage)
remoteService.observeShowAdsFlag()
// Some custom mapping between a service option and a feature flag
.map { showAds: Boolean ->
val showAdsFlag = if (showAds) ShowAds.Enabled else ShowAds.Disabled
laboratory.setOption(showAdsFlag)
}
// Scope should last for the lifetime of an application
.launchIn(GlobalScope)
Default options override¶
Whenever Laboratory reads an option for a feature flag, it falls back to a default option declared on a said flag. However, there might be cases when you’d like to change the default behavior. One example might be having features enabled by default in your debug builds and disabled on production. Or you might use feature flags for configuration, and you’d like to have a different configuration per build variant. Laboratory enables this with default options overrides.
enum class ShowAds : Feature<ShowAds> {
Enabled,
Disabled;
public override val defaultOption get() = Disabled
}
object DebugDefaultOptionFactory : DefaultOptionFactory {
override fun <T : Feature<T>> create(feature: T): Feature<*>? = when(feature) {
is ShowAds -> ShowAds.Enabled
else -> null
}
}
val laboratory = Laboratory.builder()
.featureStorage(FeatureStorage.inMemory())
.defaultOptionFactory(DebugDefaultOptionFactory)
.build()
// Uses default option declared in DebugDefaultOptionFactory
laboratory.experimentIs(ShowAds.Enabled)
You can be even more creative and, for example, enable all feature flags in your debug builds, which have an option Enabled
.
class DebugDefaultOptionFactory : DefaultOptionFactory {
override fun <T : Feature<T>> create(feature: T): Feature<*>? {
return feature.options.associateBy { it.name }["Enabled"]
}
private val <T : Feature<T>> T.options get() = javaClass.options
}
Feature flag supervision¶
Feature flags can be supervised using FeatureFlag.supervisorOption
property. Whenever supervisor has its option different from the value in this property then the supervised feature flag cannot return any other option than a default one. Option can still be set via Laboratory
but it will not be exposed as long as a feature flag is not supervised. This relationship is recursive meaning that grandparents control grandchildren indirectly.
enum class ChristmasTheme : Feature<ChristmasTheme> {
Enabled,
Disabled,
;
public override val defaultOption get() = Disabled
}
enum class Greeting : Feature<Greeting> {
Hello,
HoHoHo,
;
public override val defaultOption get() = Hello
public override val supervisorOption get() = ChristmasTheme.Enabled
}
enum class Background : Feature<Background> {
White,
Reindeer,
Snowman,
;
public override val defaultOption get() = White
public override val supervisorOption get() = ChristmasTheme.Enabled
}
val laboratory = Laboratory.inMemory()
laboratory.setOptions(Greeting.HoHoHo, Background.Reindeer)
laboratory.experimentIs(Greeting.HoHoHo) // false
laboratory.experimentIs(Background.Reindeer) // false
laboratory.setOption(ChristmasTheme.Enabled)
laboratory.experimentIs(Greeting.HoHoHo) // true
laboratory.experimentIs(Background.Reindeer) // true
Listening to remote change¶
Feature flags can be synced with a remote source with a help of OptionFactory
. Below is a sample setup using Firebase.
enum class ChristmasTheme : Feature<ChristmasTheme> {
Enabled,
Disabled,
;
public val override val defaultOption get() = Disabled
}
enum class ShowAds : Feature<ShowAds> {
Enabled,
Disabled;
public override val defaultOption get() = Disabled
}
object CustomOptionFactory : OptionFactory {
private val optionMapping = mapOf<String, (String) -> Feature<*>?>(
"ChristmasTheme" to { name -> ChristmasTheme::class.java.options.firstOrNull { it.name == name } },
"ShowAds" to { name -> ShowAds::class.java.options.firstOrNull { it.name == name } },
)
override fun create(key: String, name: String) = optionMapping[key]?.invoke(name)
}
class App : Application {
override fun onCreate() {
val firebaseStorage = FeatureStorage.inMemory()
// Get a reference to a node where feature flags are kept
val database = FirebaseDatabase.getInstance().reference.child("featureFlags")
val featureFlagListener = object : ValueEventListener {
override fun onDataChange(snapshot: DataSnapshot) {
val newOptions = (snapshot.value as? Map<*, *>)
.orEmpty()
.mapNotNull { (key, value) ->
val stringKey = key as? String ?: return@mapNotNull null
val stringValue = value as? String ?: return@mapNotNull null
CustomOptionFactory.create(stringKey, stringValue)
}
// Be cautious with using GlobalScope.
GlobalScope.launch { firebaseStorage.setOptions(newOptions) }
}
override fun onCancelled(error: DatabaseError) = Unit
}
database.child("featureFlags").addValueEventListener(featureFlagListener)
}
}