-
Notifications
You must be signed in to change notification settings - Fork 0
Best Practices
Every feature flag has three phases. Managing the lifecycle explicitly prevents flag accumulation and keeps the codebase clean.
Declare the flag as a localFlag with default = false. Add expiresAt to make the flag's expected lifetime machine-checkable.
featured {
localFlags {
boolean("new_checkout_flow", default = false) {
description = "Enable the redesigned checkout flow"
category = "checkout"
expiresAt = "2026-09-01"
}
}
}With default = false, the flag is eligible for dead-code elimination from day one. During development, override it locally via the debug UI or InMemoryConfigValueProvider in tests.
Add the flag to Firebase Remote Config (or your remote backend) and set its value to true for the target audience. The local default = false remains in the codebase; the remote value takes precedence through the provider resolution order.
Do not remove the local flag declaration during rollout — the DCE rules are still generated and still protect non-targeted builds.
Once the rollout is complete and all users are on the new path:
- Remove the
featured { localFlags { } }declaration frombuild.gradle.kts. - Delete all call sites that reference the generated accessor (
isNewCheckoutFlowEnabled()). - Remove the legacy code path that was guarded by the flag.
- Remove the flag from the remote backend.
- Remove the
#if !DISABLE_NEW_CHECKOUT_FLOWguards from Swift (iOS).
The generated R8 rules and xcconfig conditions are also removed automatically when the DSL declaration is gone.
- Use
snake_casefor flag keys in the DSL. The plugin converts tocamelCasefor Kotlin identifiers. - Name flags after the feature or behavior they gate, not their current state. Prefer
new_checkout_flowoveruse_new_checkoutorcheckout_enabled. - Use a
categorythat matches the feature module name. This groups flags in the debug UI. - Prefix flags with the module name in multi-module projects to avoid collisions:
checkout_new_flow,profile_v2.
Each feature module declares its flags via the dev.androidbroadcast.featured plugin; the app module aggregates them with dev.androidbroadcast.featured.application and passes GeneratedFeaturedRegistry.all to the debug UI. The app module owns the single ConfigValues instance and injects it into feature modules. See Multi-Module Setup for full examples.
Use fakeConfigValues { } from featured-testing in unit tests — never use real storage providers (DataStore, SharedPreferences, NSUserDefaults) in tests because they write to persistent storage and can leave state between runs.
fakeConfigValues is a suspend function that returns a ConfigValues backed by an in-memory provider. Pass initial overrides in the trailing lambda; params not mentioned fall back to their ConfigParam.defaultValue.
import dev.androidbroadcast.featured.testing.fakeConfigValues
// Build a ConfigValues with specific initial values
val configValues = fakeConfigValues {
set(GeneratedLocalFlags.newCheckoutFlow, true)
set(GeneratedLocalFlags.darkTheme, false)
}
// Read the initial value
val value = configValues.getValue(GeneratedLocalFlags.newCheckoutFlow) // true
// Simulate a flag change mid-test
configValues.override(GeneratedLocalFlags.newCheckoutFlow, false)
// … assert the code reacted to the updated valueThe companion extension ConfigValues.fake { } is also available as a convenience alias:
import dev.androidbroadcast.featured.testing.fake
val configValues = ConfigValues.fake {
set(GeneratedLocalFlags.newCheckoutFlow, true)
}A flag declared with expiresAt and never removed accumulates dead code rather than removing it. The Detekt rules bundled with featured-gradle-plugin flag expired declarations as warnings. Act on them.
Feature flags are for binary decisions: a feature is either on or off. Do not use them to store configuration values that have many possible states (URLs, timeouts, counts with unbounded ranges). Use a proper configuration system for those.
For bounded integers with known business meaning (int("max_cart_items", default = 10)), int flags are fine.
Never write if (BuildConfig.DEBUG) as a substitute for a flag. The debug build type and a feature flag serve different purposes. Use flags for features; use build types for build-time behavior differences.
DataStore and SharedPreferences providers write to persistent storage. Tests that use real providers can leave state behind and interfere with each other. Always use InMemoryConfigValueProvider or FakeConfigValues in unit tests.
featured-gradle-plugin ships Detekt rules that check:
-
expiresAtdates that have passed (stale flag warning). - Flag declarations missing
description. - Flags in
remoteFlagsthat are also declared inlocalFlagswith the same key.
Enable Detekt in your project and the rules will run as part of static analysis.
Feature flags must not be used to gate security-critical code paths. A flag that hides authentication, authorization, or encryption behind a toggle can be overridden via the debug UI in a debug build — this is intentional for development convenience, but it means you should never rely on a flag's default = false as a security boundary.
Remote flags fetched over the network are also not a substitute for server-side authorization.