Skip to content

Best Practices

kirich1409 edited this page May 19, 2026 · 2 revisions

Best Practices

Flag lifecycle

Every feature flag has three phases. Managing the lifecycle explicitly prevents flag accumulation and keeps the codebase clean.

1. Draft — introduce the flag

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.

2. Rollout — enable remotely

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.

3. Cleanup — delete the flag

Once the rollout is complete and all users are on the new path:

  1. Remove the featured { localFlags { } } declaration from build.gradle.kts.
  2. Delete all call sites that reference the generated accessor (isNewCheckoutFlowEnabled()).
  3. Remove the legacy code path that was guarded by the flag.
  4. Remove the flag from the remote backend.
  5. Remove the #if !DISABLE_NEW_CHECKOUT_FLOW guards from Swift (iOS).

The generated R8 rules and xcconfig conditions are also removed automatically when the DSL declaration is gone.

Naming conventions

  • Use snake_case for flag keys in the DSL. The plugin converts to camelCase for Kotlin identifiers.
  • Name flags after the feature or behavior they gate, not their current state. Prefer new_checkout_flow over use_new_checkout or checkout_enabled.
  • Use a category that 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.

Multi-module patterns

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.

Testing

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 value

The 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)
}

Anti-patterns

Flags that never get cleaned up

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.

Using flags for configuration values

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.

Hardcoding flag values in production code

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.

Testing with real providers

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.

Automated enforcement (Detekt)

featured-gradle-plugin ships Detekt rules that check:

  • expiresAt dates that have passed (stale flag warning).
  • Flag declarations missing description.
  • Flags in remoteFlags that are also declared in localFlags with the same key.

Enable Detekt in your project and the rules will run as part of static analysis.

Security

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.

Clone this wiki locally