How do you structure feature flags in a large, modularized Android app?
Wiring every feature module to Firebase Remote Config couples your whole app to one vendor — a small flag API module is what keeps it swappable.
LEARN BY DOINGPut your intuition to work3 steps
How do you structure feature flags in a large, modularized Android app?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.A modularized app usually has a :feature:checkout, a :feature:search, a :feature:rewards, each built and tested in isolation, plus an :app module that assembles them. Feature flags cut across every one of those modules — a kill switch for checkout, a gradual rollout for a new search ranking, an A/B test on rewards copy — and the naive move is to add firebase-config as a dependency to each feature module and call FirebaseRemoteConfig.getInstance().getBoolean(...) wherever it's needed. That works on day one and turns into a real problem by month six: every feature module now depends on a specific vendor SDK, every feature module's unit tests need to fake or mock Firebase instead of a small interface, and swapping vendors — or even just testing a kill switch offline — means touching a dozen modules instead of one.
#Two kinds of flags, and they don't belong in the same place
It's worth separating compile-time flags from runtime flags before designing anything. A compile-time flag — enabling a whole feature only in an internal QA build — belongs in Gradle build variants and BuildConfig fields; it's baked into the binary and never changes without a new build. A runtime flag — a kill switch, a percentage rollout, an A/B variant — has to be checked while the app is running and has to be able to change without shipping a new APK. The architecture below is for the second kind.
#The API module every feature depends on
// :core:feature-flags — pure Kotlin, zero vendor dependencies.enum class FeatureFlag(val key: String, val defaultValue: Boolean) {NEW_CHECKOUT_FLOW("new_checkout_flow", defaultValue = false),ONE_CLICK_REORDER("one_click_reorder", defaultValue = false),DISABLE_SOCIAL_LOGIN("disable_social_login", defaultValue = false), // kill switch}interface FeatureFlagProvider {fun isEnabled(flag: FeatureFlag): Boolean}
Every feature module depends on this module and nothing else related to flags. :feature:checkout never imports com.google.firebase.*, never mocks FirebaseRemoteConfig in a test, and doesn't care whether the real implementation is Remote Config, LaunchDarkly, or a config file — it only knows about FeatureFlagProvider.
#The one module allowed to know Firebase exists
// :core:feature-flags-implclass RemoteFeatureFlagProvider @Inject constructor(private val remoteConfig: FirebaseRemoteConfig,) : FeatureFlagProvider {override fun isEnabled(flag: FeatureFlag): Boolean =// getBoolean falls back to the in-APK defaults set via// remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults),// so a flag still resolves sanely before the first fetch completes.remoteConfig.getBoolean(flag.key)}class DebugFeatureFlagProvider @Inject constructor(private val overrides: DataStore<Preferences>,) : FeatureFlagProvider {override fun isEnabled(flag: FeatureFlag): Boolean = runBlocking {val key = booleanPreferencesKey(flag.key)overrides.data.first()[key] ?: flag.defaultValue}suspend fun setOverride(flag: FeatureFlag, enabled: Boolean) {overrides.edit { it[booleanPreferencesKey(flag.key)] = enabled }}}
// :app — the composition root. Feature modules never see this file.@Module@InstallIn(SingletonComponent::class)abstract class FeatureFlagModule {@Bindsabstract fun bindFeatureFlagProvider(impl: RemoteFeatureFlagProvider, // swapped for DebugFeatureFlagProvider via a debug-only DI module): FeatureFlagProvider}// Usage inside a feature module — depends only on :core:feature-flags.class CheckoutViewModel @Inject constructor(private val featureFlags: FeatureFlagProvider,) : ViewModel() {val useNewCheckoutFlow: Booleanget() = featureFlags.isEnabled(FeatureFlag.NEW_CHECKOUT_FLOW)}
#How the module graph looks
Notice the dependency arrows: feature modules point at the API module, the implementation module points at the API module too (it implements the interface) and at Firebase, and only :app depends on the implementation. No feature module has a path in the graph that reaches Firebase directly — a Gradle-level guarantee, not just a convention people are supposed to remember.
#The part teams forget: deleting flags
Testing benefits from the same inversion: a unit test for CheckoutViewModel constructs a trivial FakeFeatureFlagProvider that returns a fixed map of booleans, with no Firebase SDK, no network, and no flakiness tied to remote state. That's a second, independent payoff of the same architectural decision — decoupling from the vendor for production flexibility also decouples the tests from the vendor for determinism.
🧠 Check your understanding
0/4 · 0/4 answered1. Why should feature modules depend on a FeatureFlagProvider interface instead of Firebase Remote Config directly?
2. Why ship local default values for each flag inside the APK rather than relying solely on the remote fetch?
3. What is 'flag debt'?
4. What's the main advantage of a DebugFeatureFlagProvider backed by local DataStore in debug builds?