Why Every Android Team Needs Feature Flags
Architecture: The Feature Flag Layer
// Domain layer: the interface your app depends on
interface FeatureFlags {
fun isEnabled(flag: Flag): Boolean
fun getString(flag: StringFlag): String
fun getInt(flag: IntFlag): Int
fun observe(flag: Flag): Flow<Boolean>
}
// Type-safe flag definitions
sealed class Flag(
val key: String,
val defaultValue: Boolean,
val owner: String, // Team or person responsible
val expiresAt: String? // ISO date -- when to clean up
) {
data object NewCheckoutFlow : Flag(
key = "new_checkout_flow",
defaultValue = false,
owner = "payments-team",
expiresAt = "2026-06-01"
)
data object ComposeNavigation : Flag(
key = "compose_navigation",
defaultValue = false,
owner = "platform-team",
expiresAt = "2026-05-15"
)
data object DarkModeV2 : Flag(
key = "dark_mode_v2",
defaultValue = false,
owner = "design-team",
expiresAt = null // Permanent flag
)
data object AiRecommendations : Flag(
key = "ai_recommendations",
defaultValue = false,
owner = "ml-team",
expiresAt = "2026-07-01"
)
}
sealed class StringFlag(
val key: String,
val defaultValue: String
) {
data object CheckoutButtonLabel : StringFlag(
key = "checkout_button_label",
defaultValue = "Place Order"
)
}
sealed class IntFlag(val key: String, val defaultValue: Int) {
data object MaxCartItems : IntFlag(
key = "max_cart_items",
defaultValue = 50
)
}Firebase Remote Config Implementation
class FirebaseFeatureFlagProvider(
private val remoteConfig: FirebaseRemoteConfig,
private val localCache: DataStore<Preferences>,
private val analytics: Analytics
) : FeatureFlags {
init {
// Set defaults from flag definitions
val defaults = Flag::class.sealedSubclasses.associate { subclass ->
val flag = subclass.objectInstance!!
flag.key to flag.defaultValue
}
remoteConfig.setDefaultsAsync(defaults)
// Fetch with 1-hour cache for production, 0 for debug
val cacheExpiration = if (BuildConfig.DEBUG) 0L else 3600L
remoteConfig.fetchAndActivate()
}
override fun isEnabled(flag: Flag): Boolean {
val value = remoteConfig.getBoolean(flag.key)
// Track flag evaluation for analytics
analytics.logFlagEvaluation(flag.key, value)
return value
}
override fun getString(flag: StringFlag): String {
return remoteConfig.getString(flag.key).ifEmpty { flag.defaultValue }
}
override fun getInt(flag: IntFlag): Int {
return remoteConfig.getLong(flag.key).toInt()
}
override fun observe(flag: Flag): Flow<Boolean> = callbackFlow {
// Emit current value immediately
trySend(isEnabled(flag))
// Listen for remote config updates
val listener = ConfigUpdateListener { configUpdate ->
if (configUpdate.updatedKeys.contains(flag.key)) {
remoteConfig.activate().addOnCompleteListener {
trySend(isEnabled(flag))
}
}
}
remoteConfig.addOnConfigUpdateListener(listener)
awaitClose { /* listener auto-removed */ }
}
}
// Hilt module for DI
@Module
@InstallIn(SingletonComponent::class)
object FeatureFlagModule {
@Provides
@Singleton
fun provideFeatureFlags(
remoteConfig: FirebaseRemoteConfig,
@ApplicationContext context: Context,
analytics: Analytics
): FeatureFlags = FirebaseFeatureFlagProvider(
remoteConfig = remoteConfig,
localCache = context.flagCacheDataStore,
analytics = analytics
)
}Using Feature Flags in Compose UI
@Composable
fun CheckoutScreen(
viewModel: CheckoutViewModel = hiltViewModel()
) {
val useNewFlow by viewModel.newCheckoutEnabled
.collectAsStateWithLifecycle()
val buttonLabel by viewModel.checkoutButtonLabel
.collectAsStateWithLifecycle()
if (useNewFlow) {
NewCheckoutFlow(
buttonLabel = buttonLabel,
onComplete = viewModel::onCheckoutComplete
)
} else {
LegacyCheckoutFlow(
onComplete = viewModel::onCheckoutComplete
)
}
}
class CheckoutViewModel @Inject constructor(
private val featureFlags: FeatureFlags
) : ViewModel() {
val newCheckoutEnabled: StateFlow<Boolean> =
featureFlags.observe(Flag.NewCheckoutFlow)
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val checkoutButtonLabel: StateFlow<String> =
flowOf(featureFlags.getString(StringFlag.CheckoutButtonLabel))
.stateIn(viewModelScope, SharingStarted.Eagerly, "Place Order")
}
// In NavHost -- gate entire destinations behind flags
@Composable
fun AppNavHost(
navController: NavHostController,
featureFlags: FeatureFlags
) {
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
if (featureFlags.isEnabled(Flag.AiRecommendations)) {
composable("recommendations") {
AiRecommendationsScreen()
}
}
}
}Debug Overrides and QA Tools
// Debug override layer that wraps the real provider
class DebugFeatureFlagProvider(
private val realProvider: FeatureFlags,
private val overrideStore: DataStore<Preferences>
) : FeatureFlags {
private val overrideKeyPrefix = "override_"
override fun isEnabled(flag: Flag): Boolean {
// Check for local override first
val overrideKey = booleanPreferencesKey(
"${overrideKeyPrefix}${flag.key}"
)
val override = runBlocking {
overrideStore.data.first()[overrideKey]
}
return override ?: realProvider.isEnabled(flag)
}
suspend fun setOverride(flag: Flag, value: Boolean) {
val key = booleanPreferencesKey(
"${overrideKeyPrefix}${flag.key}"
)
overrideStore.edit { it[key] = value }
}
suspend fun clearOverride(flag: Flag) {
val key = booleanPreferencesKey(
"${overrideKeyPrefix}${flag.key}"
)
overrideStore.edit { it.remove(key) }
}
suspend fun clearAllOverrides() {
overrideStore.edit { prefs ->
prefs.asMap().keys
.filter { it.name.startsWith(overrideKeyPrefix) }
.forEach { prefs.remove(it) }
}
}
override fun getString(flag: StringFlag) = realProvider.getString(flag)
override fun getInt(flag: IntFlag) = realProvider.getInt(flag)
override fun observe(flag: Flag) = realProvider.observe(flag)
}Flag Hygiene: Expiration and Cleanup
Key Takeaways
- 1Feature flags decouple deployment from release -- turn features on/off in 30 seconds instead of waiting days for a Play Store update.
- 2Wrap Remote Config behind a typed FeatureFlags interface with sealed class flag definitions for compile-time safety.
- 3Use percentage-based rollouts: 1% -> 5% -> 25% -> 50% -> 100%, monitoring crash rates at each stage.
- 4Every flag needs an owner, an expiration date, and a cleanup plan -- stale flags create combinatorial testing nightmares.
- 5Debug overrides via DataStore let QA test every flag combination without Remote Config changes.
- 6Teams with flag hygiene maintain 15-25 active flags; teams without accumulate 200+ and lose track of the real code path.
Frequently Asked
Should I use feature flags for everything?
No. Use flags for risky features, A/B tests, and gradual rollouts. Don't use flags for stable features - they add complexity. Clean up old flags after full rollout.
How do I test feature flags?
Test with all flag combinations. Use local overrides in development. Test flag fetch and cache behavior. Include flag states in crash reports.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.