Skip to content

Multi Module Setup

kirich1409 edited this page Jun 13, 2026 · 4 revisions

Multi-Module Setup

In a multi-module project two plugin IDs cooperate:

  • dev.androidbroadcast.featured — the producer plugin. Apply to every module that declares flags in featured { }. Each module publishes a featured-manifest.json artifact alongside its compiled outputs.
  • dev.androidbroadcast.featured.application — the aggregator plugin. Apply once, in the module that owns the single ConfigValues instance (typically the app or a shared KMP module). Reads featured-manifest.json from each featuredAggregation(...) dependency and generates GeneratedFeaturedRegistry.

Apply the producer plugin per feature module

// :feature:checkout/build.gradle.kts
plugins {
    id("dev.androidbroadcast.featured")
}

featured {
    localFlags {
        boolean("new_checkout", default = false) {
            description = "Enable the redesigned checkout flow"
            category = "checkout"
        }
        enum(
            key = "checkout_variant",
            typeFqn = "com.example.CheckoutVariant",
            default = "LEGACY",
        ) {
            description = "Controls which checkout variant is shown"
            category = "checkout"
        }
    }
    remoteFlags {
        boolean("promo_banner_enabled", default = false) {
            description = "Show the promotional banner"
            category = "promotions"
        }
    }
}

This generates per-module objects such as GeneratedLocalFlagsFeatureCheckout and GeneratedRemoteFlagsFeatureCheckout. Each object name includes a module-derived suffix to avoid JVM class-name collisions when multiple feature modules land on the same classpath.

Observe-bridge pattern

Rather than having consumers import the generated objects directly, each feature module ships thin public extension functions on ConfigValues — the observe-bridge:

// :feature:checkout/src/commonMain/.../CheckoutFlagObservers.kt
public fun ConfigValues.newCheckoutFlow(): Flow<Boolean> =
    observe(GeneratedLocalFlagsFeatureCheckout.newCheckout).map { it.value }

public suspend fun ConfigValues.setNewCheckout(value: Boolean) {
    override(GeneratedLocalFlagsFeatureCheckout.newCheckout, value)
}

Consumers call configValues.newCheckoutFlow() — the generated class name stays an implementation detail. See Sample App for the full working example.

Apply the aggregator plugin

Apply dev.androidbroadcast.featured.application in the module that owns ConfigValues and wire the generated source directory:

// :app/build.gradle.kts  (or :sample:shared for a KMP shared module)
plugins {
    id("dev.androidbroadcast.featured.application")
}

dependencies {
    // Primitive-only modules: featuredAggregation is enough.
    featuredAggregation(project(":feature-promotions"))

    // Enum-flag modules: also a regular api/implementation dep so the enum
    // class is on the compile classpath at the aggregator level.
    featuredAggregation(project(":feature-checkout"))
    api(project(":feature-checkout"))
}

// Wire the generated source set (KMP style):
sourceSets.commonMain.get().kotlin.srcDir(
    tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile },
)

// Non-KMP / single-platform style:
// kotlin.sourceSets.commonMain.kotlin.srcDir(
//     layout.buildDirectory.dir("generated/featured/commonMain"),
// )

The plugin generates:

object GeneratedFeaturedRegistry {
    val all: List<ConfigParam<*>> = listOf(/* all aggregated flags */)
}

Pass GeneratedFeaturedRegistry.all wherever a flag list is needed — typically the debug UI:

FeatureFlagsDebugScreen(
    configValues = configValues,
    registry = GeneratedFeaturedRegistry.all,
)

Resolving flags across all modules

The plugin registers a resolveFeatureFlags task per module. To resolve flags across every module that applies the plugin, use Gradle's built-in name-matched task invocation — no project path, no root aggregator task:

# Resolve flags across all modules (runs resolveFeatureFlags in every module that applies the plugin)
./gradlew resolveFeatureFlags

# Generate R8 rules for all Android modules
./gradlew generateFeaturedProguardRules

# Generate xcconfig across all modules
./gradlew generateXcconfig

Since v1.2.0 the plugin no longer registers a scanAllLocalFlags root task. The name-matched ./gradlew resolveFeatureFlags invocation is the equivalent, and it keeps the plugin Project-Isolation-safe (no rootProject access).

Single ConfigValues in the app module

Feature modules declare their own ConfigParam objects but do not create ConfigValues. A single ConfigValues instance lives in the app module and is injected into feature modules through dependency injection:

// :app/src/main/kotlin/AppModule.kt (example with manual DI)
val configValues = ConfigValues(
    localProvider = DataStoreConfigValueProvider(dataStore),
)

// Inject into :feature:checkout
val checkoutViewModel = CheckoutViewModel(configValues)

Feature modules read flags through the generated extension functions on ConfigValues:

// In :feature:checkout
class CheckoutViewModel(private val configValues: ConfigValues) : ViewModel() {
    val isNewFlowEnabled: StateFlow<Boolean> =
        configValues.observe(GeneratedLocalFlags.checkoutNewFlow)
            .map { it.value }
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
}

Why generated object names are module-suffixed

When multiple feature modules sit on the same JVM classpath (e.g. all in :sample:shared via api(project(...))), plain object GeneratedLocalFlags in each module would produce a duplicate-class error at link time. The generator appends a suffix derived from the Gradle project path — GeneratedLocalFlagsSampleFeatureCheckout, GeneratedLocalFlagsSampleFeatureUi, etc. — making each name unique. @file:JvmName is therefore not needed and is not emitted.

Known limitation: wireProguardToVariants

wireProguardToVariants (which injects per-flag R8 rules into Android build variants) operates via the AGP Variant API and applies only to com.android.library and com.android.application targets. It is a no-op on com.android.kotlin.multiplatform.library targets — those modules must configure ProGuard rules manually if needed.

Isolated projects

As of v1.2.0 the plugin is both Configuration Cache safe and Project-Isolation-safe. The scanAllLocalFlags root task and its wireToRootAggregator() wiring (the only rootProject access) were removed — flag resolution across all modules now uses the built-in ./gradlew resolveFeatureFlags invocation. Cross-module registry aggregation goes through the declarative featuredAggregation configuration, which was already isolation-safe. See Known Limitations for history.

Clone this wiki locally