All Decisions

ADR-0028: Feature Flag Architecture

DateFebruary 24, 2026
CategoryOnboarding & Features
Tags
feature-flagssettings-persistence

Context

  • LocusFlow's roadmap introduces capabilities incrementally (LLM-assisted reflection, morning briefing, weekly synthesis, habit tracking). Some features will be experimental, incomplete, or device-dependent when first shipped.
  • Phase 6 (Settings) lists "feature toggles for experimental functionality" as a scope item.
  • The app is offline-only (ADR-0005) with no network stack, ruling out cloud-based feature flag services (LaunchDarkly, Firebase Remote Config, etc.).
  • Settings persistence uses Jetpack DataStore (ADR-0027), which provides a natural home for boolean flags.
  • Feature toggles must be observable (Flow-based) so the UI can reactively show or hide features without restart.
  • The app targets pre-release testers initially; feature flags serve as safety valves, not A/B testing infrastructure.

Constraints:

  • No network: flags must be fully local.
  • Minimal overhead: this is a small, single-developer productivity app, not a platform with thousands of flags.
  • Must integrate with existing MVVM + Repository + Hilt architecture.

Decision

We will implement a local, DataStore-backed feature flag system with compile-time flag definitions and runtime override capability.

Feature Flag Registry

All flags are defined in a single Kotlin object, providing compile-time discoverability and preventing key typos:

// domain/featureflags/FeatureFlags.kt
object FeatureFlags {
    val LLM_REFLECTION_SUMMARY = FeatureFlag(
        key = "feature_llm_reflection_summary",
        defaultEnabled = true,
        description = "LLM-powered daily reflection summaries"
    )
    val LLM_MORNING_BRIEFING = FeatureFlag(
        key = "feature_llm_morning_briefing",
        defaultEnabled = false,
        description = "LLM-generated morning briefing"
    )
    val LLM_WEEKLY_SYNTHESIS = FeatureFlag(
        key = "feature_llm_weekly_synthesis",
        defaultEnabled = false,
        description = "LLM-assisted weekly synthesis"
    )
    val HABIT_TRACKING = FeatureFlag(
        key = "feature_habit_tracking",
        defaultEnabled = false,
        description = "Habit definition and tracking"
    )

    /** All registered flags, for Settings screen enumeration. */
    val all: List<FeatureFlag> = listOf(
        LLM_REFLECTION_SUMMARY,
        LLM_MORNING_BRIEFING,
        LLM_WEEKLY_SYNTHESIS,
        HABIT_TRACKING,
    )
}

data class FeatureFlag(
    val key: String,
    val defaultEnabled: Boolean,
    val description: String,
)

Feature Flag Repository

// domain/repository/FeatureFlagRepository.kt
interface FeatureFlagRepository {
    fun isEnabled(flag: FeatureFlag): Flow<Boolean>
    suspend fun setEnabled(flag: FeatureFlag, enabled: Boolean)
    suspend fun resetToDefaults()
}

The implementation (DataStoreFeatureFlagRepository) reads and writes to the same Preferences DataStore instance used by SettingsRepository (ADR-0027), using the flag's key string directly.

Architecture integration

app/ ├── domain/ │ ├── featureflags/ │ │ └── FeatureFlags.kt # Flag definitions + FeatureFlag data class │ └── repository/ │ └── FeatureFlagRepository.kt # Interface ├── data/ │ └── settings/ │ └── DataStoreFeatureFlagRepository.kt # DataStore implementation └── di/ └── SettingsModule.kt # Binds FeatureFlagRepository (reuses DataStore singleton)

Usage patterns

In ViewModels — observe a flag to control behavior:

class ReflectionViewModel @Inject constructor(
    private val featureFlagRepository: FeatureFlagRepository,
    // ...
) : ViewModel() {
    val isLlmSummaryEnabled = featureFlagRepository
        .isEnabled(FeatureFlags.LLM_REFLECTION_SUMMARY)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
}

In Navigation / UI — conditionally show destinations:

if (isFeatureEnabled) {
    composable(NavigationRoutes.MORNING) { MorningScreen(...) }
}

In Settings screen — enumerate all flags with toggle switches:

FeatureFlags.all.forEach { flag ->
    SettingsToggleRow(
        label = flag.description,
        checked = flagStates[flag.key] ?: flag.defaultEnabled,
        onCheckedChange = { viewModel.toggleFlag(flag, it) }
    )
}

Scope boundaries

  • This ADR covers the flag definition, storage, observation, and UI toggle mechanism.
  • It does NOT cover:
    • Server-side or remote flag delivery (ruled out by ADR-0005).
    • A/B testing or analytics integration.
    • Gradual rollout percentages (not applicable to a single-user local app).
    • Build-variant-only flags (e.g., BuildConfig.DEBUG); those remain compile-time constants and are orthogonal to this system.

Rationale

  • Centralized registry over scattered string keys: A single FeatureFlags object prevents key duplication, enables IDE auto-complete, and makes it trivial to enumerate all flags in the Settings UI.
  • DataStore over Room: Feature flags are boolean key-value pairs, not relational data. DataStore (already chosen for settings in ADR-0027) is the natural fit. Reusing the same DataStore instance avoids additional files and singleton management.
  • Runtime toggles over build-time flags: Users and testers need to enable/disable experimental features without rebuilding the app. BuildConfig flags are useful for debug-vs-release distinctions but cannot be changed at runtime.
  • Flow-based observation: Consistent with the reactive state model (ADR-0006). When a flag changes, all observing ViewModels and UI components update automatically.
  • Reset to defaults: A single resetToDefaults() method supports the "clear all feature flags" action in the data management section of Settings, without affecting other settings.
  • Simplicity: This is deliberately lightweight. No flag evaluation engine, no conditions, no targeting rules. A boolean per flag is sufficient for a local, single-user app in pre-release.

Consequences

  • Positive:

    • Experimental features can ship behind flags, reducing risk of incomplete functionality reaching testers.
    • Settings screen can auto-generate flag toggles from the registry, reducing boilerplate.
    • Feature flags are testable: inject a fake FeatureFlagRepository that returns controlled values.
    • Adding a new flag is a one-line addition to the FeatureFlags object plus a DataStore key.
    • No additional dependencies beyond DataStore (already in the project via ADR-0027).
  • Downsides:

    • All flags share one DataStore file with settings; a corrupted DataStore affects both. Mitigation: DataStore handles corruption gracefully with a fallback to defaults.
    • No remote override capability; testers must toggle flags manually in-app. Acceptable for current scale.
    • Flag proliferation risk if discipline is not maintained. Mitigation: review flags quarterly; remove flags for features that are fully stable.
  • Follow-up work:

    • Implement FeatureFlags registry, FeatureFlagRepository, and DataStoreFeatureFlagRepository.
    • Add a "Feature Flags" or "Experimental" section to the Settings screen.
    • Gate LLM-dependent features (morning briefing, weekly synthesis) behind their respective flags.
    • Define a flag lifecycle policy: when a feature graduates from experimental to stable, remove its flag and hard-enable the feature.

Alternatives Considered

  • Firebase Remote Config — Rejected: requires network access, violating ADR-0005. Also introduces a Google dependency counter to the privacy-first stance.
  • Room table for flags — Rejected: same reasoning as ADR-0027; flags are key-value, not relational.
  • BuildConfig fields only — Rejected: cannot be changed at runtime; requires app rebuild for each toggle change. Useful for debug/release distinctions but not for user-facing feature toggles.
  • Separate DataStore file for flags — Rejected: adds a second DataStore instance to manage. Flags are few and simple; colocating with settings in one DataStore file is cleaner.
  • Third-party local flag library (e.g., Unleash client in offline mode) — Rejected: adds a dependency for a problem that can be solved with ~50 lines of code.

Notes

  • Debug builds may hard-enable all flags for development convenience. This can be done via a DebugFeatureFlagRepository that wraps the real one and overrides isEnabled to always return true, or by calling resetToDefaults() with debug-specific defaults.
  • Feature flags defined here complement, but do not replace, the BuildConfig.DEBUG compile-time constant. Use BuildConfig.DEBUG for developer-only tooling (e.g., strict mode, logging); use feature flags for user-visible functionality that may or may not be ready.
  • The FeatureFlag.description field is user-facing and should be written in plain language suitable for the Settings screen.