How Keeps 30+ Gradle Modules DRY with Convention Plugins

skydovesJaewoong Eum (skydoves)||21 min read

How Keeps 30+ Gradle Modules DRY with Convention Plugins

Every Android developer who has grown a project past a handful of modules knows the ritual. You create a new module, open its build.gradle.kts, and start copying: the android {} block with compileSdk and minSdk, the Java version, the Kotlin compiler options, the test runner, the product flavors, the Compose setup, the Hilt and KSP dependencies. By the time the module compiles you have reproduced sixty lines that already exist, nearly identical, in a dozen other build files. Bump compileSdk in one and the others silently drift. The Now in Android sample answers this with convention plugins, collapsing each of those sixty line build files into two or three plugin aliases. The surface looks like a shorter build file. The deeper question is how a single line like alias(libs.plugins.nowinandroid.android.library) expands into an entire module configuration, and what Gradle machinery makes that one alias resolvable from every one of thirty plus modules.

In this article, you'll dive deep into how Now in Android keeps its module builds DRY, exploring what a convention plugin actually is as a Plugin<Project>, how its apply() both applies other plugins and configures their extensions, how the Android library plugin encodes an entire module convention, how the Compose and Hilt plugins layer and react on top of it, how the api and impl feature split keeps compilation shallow, how build-logic is wired as an included build that publishes binary plugins, how a version catalog alias maps through a generated accessor to a plugin id and an implementationClass, and why this approach beats subprojects {}, allprojects {}, or apply from.

The fundamental problem: The same sixty lines in every build file

Picture the build.gradle.kts for a typical core module before any deduplication. It has to spell out every setting the Android Gradle Plugin needs before the module will build:

android {
    compileSdk = 36
    defaultConfig {
        minSdk = 23
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    flavorDimensions += "contentType"
    productFlavors {
        register("demo") { dimension = "contentType" }
        register("prod") { dimension = "contentType" }
    }
}

Now add the Kotlin compiler options, core library desugaring, the Compose build feature and its dependency set, and the baseline test libraries, and you are at sixty lines. Then write those sixty lines again for the next module, and the next. The duplication is not just tedious, it is a correctness hazard: the moment two modules disagree on compileSdk or the instrumentation runner, you have a bug that no single file makes visible.

The first instinct is to lift the shared block to the root build file and push it down with subprojects {} or allprojects {}:

subprojects {
    apply(plugin = "com.android.library")
    extensions.configure<LibraryExtension> {
        compileSdk = 36
        defaultConfig { minSdk = 23 }
    }
}

This runs from the root, eagerly, over every subproject, and it forces each one into a single shape. But the app module is an application, not a library, and a util module is plain Kotlin with no android {} block at all, so the assumption is already false. Configuring from the root also reaches across project boundaries, which is exactly what configuration avoidance and project isolation are built to prevent. You end up with a construct that is eager, untyped in places, and structurally opposed to the direction Gradle is moving. Convention plugins take the opposite stance.

What a convention plugin is: a Plugin that applies and configures

A convention plugin is not a special Gradle type. It is an ordinary Plugin<Project>, the same interface the Android Gradle Plugin itself implements. The entire interface is one method:

interface Plugin<T> {
    fun apply(target: T)
}

When Gradle applies a plugin to a project, it instantiates the class and calls apply(project). A full plugin like AGP uses that call to register tasks and extensions. A convention plugin uses it for something narrower: inside apply() it applies other plugins and configures the extensions those plugins registered. Two verbs carry the whole design, apply and configure.

Those two verbs are not interchangeable, and the distinction drives everything that follows. Applying a plugin makes its tasks and extensions exist. Configuring an extension mutates settings on a plugin that has already been applied. The order is fixed:

apply(plugin = "com.android.library")
extensions.configure<LibraryExtension> {
    // LibraryExtension exists only because the line above ran first
}

You cannot configure LibraryExtension before com.android.library has registered it, so every Now in Android plugin applies its base plugins first and configures second. Keep this apply then configure ordering in mind, because one family of plugins deliberately breaks it, and the reason it can is instructive.

The catalog Now in Android ships

Before tracing individual plugins, here is the set the sample ships, each identified by the id you apply it with:

  • nowinandroid.android.application and nowinandroid.android.library: turn a module into an app or a library and apply the shared Android and Kotlin configuration.
  • nowinandroid.android.application.compose and nowinandroid.android.library.compose: layer Compose on top of the app or library plugin.
  • nowinandroid.android.feature.api and nowinandroid.android.feature.impl: the two halves of the feature split.
  • nowinandroid.hilt: dependency injection that reacts to whichever base plugin is present.
  • nowinandroid.android.room: Room persistence wired through KSP.
  • nowinandroid.jvm.library: a pure Kotlin, non-Android module.
  • nowinandroid.android.lint, nowinandroid.android.test, and the ...jacoco, ...application.firebase, and ...application.flavors variants: focused plugins for lint, test modules, coverage, Firebase, and standalone flavor configuration.
  • nowinandroid.root: applied only at the root project.

Inside the workhorse: the Android library plugin

Nearly every core and feature module applies one plugin, nowinandroid.android.library, and its implementation is the template for the rest. AndroidLibraryConventionPlugin opens by applying two plugins:

abstract class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "com.android.library")
            apply(plugin = "nowinandroid.android.lint")
            // ...
        }
    }
}

The with(target) wrapper lets the rest of the body read as though written inside the project. It applies com.android.library, the real AGP library plugin that registers LibraryExtension, and then nowinandroid.android.lint, another convention plugin. Notice the composition already: one convention plugin applying another.

With LibraryExtension now registered, the plugin configures it:

extensions.configure<LibraryExtension> {
    configureKotlinAndroid(this)
    testOptions.targetSdk = 36
    lint.targetSdk = 36
    defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    testOptions.animationsDisabled = true
    configureFlavors(this)
    configureGradleManagedDevices(this)
    resourcePrefix =
        path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_")
            .lowercase() + "_"
}

Every setting a module would otherwise repeat lives here once: the test and lint SDK, the instrumentation runner, disabled animations, the demo and prod flavors through configureFlavors, and managed test devices. The resourcePrefix line derives a per module resource prefix from the module path, splitting on non word characters, dropping the leading empty segment, deduplicating, and joining with underscores, so :core:module1 becomes core_module1_. That prefix keeps resource names from colliding across modules without anyone setting it by hand.

Beyond the main extension, the plugin configures the variant components extension and adds a baseline set of dependencies:

extensions.configure<LibraryAndroidComponentsExtension> {
    configurePrintApksTask(this)
    disableUnnecessaryAndroidTests(target)
}
configureSpotlessForAndroid()
dependencies {
    "androidTestImplementation"(libs.findLibrary("kotlin.test").get())
    "testImplementation"(libs.findLibrary("kotlin.test").get())
    "testImplementation"(libs.findLibrary("junit").get())
    "implementation"(libs.findLibrary("androidx.tracing.ktx").get())
}

LibraryAndroidComponentsExtension is the AGP variant API, distinct from LibraryExtension, used here to register per variant tasks and to skip Android tests for modules that have none. The dependencies block adds the test libraries every module shares. Two details point ahead: dependencies are registered by their string configuration names such as "testImplementation", and each library is looked up with libs.findLibrary(...) rather than a generated accessor. Both are consequences of running inside plugin code rather than a build script, which a later section unpacks.

Sharing Kotlin configuration across plugins

The configureKotlinAndroid(this) call at the top of that block is where the application plugin and the library plugin share code. Both invoke the same Project extension function:

internal fun Project.configureKotlinAndroid(
    commonExtension: CommonExtension,
) {
    commonExtension.apply {
        compileSdk = 36
        defaultConfig.apply { minSdk = 23 }
        compileOptions.apply {
            sourceCompatibility = JavaVersion.VERSION_11
            targetCompatibility = JavaVersion.VERSION_11
            isCoreLibraryDesugaringEnabled = true
        }
    }
    configureKotlin<KotlinAndroidProjectExtension>()
    dependencies {
        "coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
    }
}

The parameter type is CommonExtension, the shared supertype of ApplicationExtension and LibraryExtension, so one function configures either. It sets compileSdk, minSdk, Java 11, core library desugaring, and the desugaring dependency. The point is placement: the compile SDK, min SDK, and Java version exist in exactly one location. Change compileSdk = 36 here and every module that applies an app or library plugin moves together.

The Kotlin compiler options are shared one level deeper, in a private generic helper:

private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() = configure<T> {
    val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map { it.toBoolean() }.orElse(false)
    when (this) {
        is KotlinAndroidProjectExtension -> compilerOptions
        is KotlinJvmProjectExtension -> compilerOptions
        else -> TODO("Unsupported project extension $this ${T::class}")
    }.apply {
        jvmTarget = JvmTarget.JVM_11
        allWarningsAsErrors = warningsAsErrors
        freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
        freeCompilerArgs.add("-Xconsistent-data-class-copy-visibility")
    }
}

The reified type parameter lets the same function configure either the Android Kotlin extension or the JVM Kotlin extension, chosen by the caller. It reads a Gradle property so CI can flip warnings into errors, sets jvmTarget to 11, and adds the opt in compiler arguments. The TODO branch is real, current state code rather than something dead: it fails loudly if a future caller passes an unsupported extension type. Because both the Android and the pure JVM library plugins route through this one function, they cannot disagree on their Kotlin settings.

Layering behavior: how Compose opts in

Compose is not part of the base library plugin. A plain library module has compose = false. A module that needs Compose applies a second plugin, nowinandroid.android.library.compose, on top of the first, and that plugin does something the base plugin does not:

class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "com.android.library")
            apply(plugin = "org.jetbrains.kotlin.plugin.compose")

            val extension = extensions.getByType<LibraryExtension>()
            configureAndroidCompose(extension)
        }
    }
}

The key line is extensions.getByType<LibraryExtension>(), not extensions.configure. Where configure registers a callback to mutate an extension, getByType reads an extension that already exists and hands the same instance onward. Because it applies com.android.library itself, LibraryExtension is guaranteed to exist by the time getByType runs; in practice the base library plugin has already applied it, so applying it again changes nothing. It then fetches that instance and passes it to the Compose helper. This is layering rather than replacement: the Compose plugin adds Compose to a module the library plugin already shaped.

The configureAndroidCompose helper, shared by both the library and application Compose plugins, flips the build feature and wires the dependencies:

internal fun Project.configureAndroidCompose(
    commonExtension: CommonExtension,
) {
    commonExtension.apply {
        buildFeatures.apply { compose = true }
        dependencies {
            val bom = libs.findLibrary("androidx-compose-bom").get()
            "implementation"(platform(bom))
            "androidTestImplementation"(platform(bom))
            "implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get())
            "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get())
        }
    }
    // ...
}

It turns Compose on, then adds the Compose BOM as a platform so every Compose artifact resolves to one aligned version, plus the preview and debug tooling. The CommonExtension parameter again means one helper serves both the app and the library.

It also gates optional Compose compiler metrics and reports behind Gradle properties, then points the compiler at a shared stability configuration file:

extensions.configure<ComposeCompilerGradlePluginExtension> {
    // ... gates enableComposeCompilerMetrics / enableComposeCompilerReports here
    stabilityConfigurationFiles
        .add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
}

This is another apply then configure, aimed at the Compose compiler plugin applied a few lines earlier. Note isolated.rootProject, which reaches the root directory in a way that does not violate project isolation, a small preview of the section on why this model outlasts subprojects {}.

Reacting to what is present: the Hilt plugin

The dependency injection plugin, nowinandroid.hilt, uses a different technique. It does not know in advance whether the module it lands on is an Android module or a pure Kotlin one, so instead of taking a flag, it reacts to whichever base plugin turns out to be present. It begins unconditionally:

class HiltConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "com.google.devtools.ksp")

            dependencies {
                "ksp"(libs.findLibrary("hilt.compiler").get())
                "ksp"(libs.findLibrary("kotlin.metadata").get())
            }
            // ...
        }
    }
}

KSP is applied for every module, because Hilt's annotation processing runs through it regardless of module type, and the two compiler artifacts go on the ksp configuration. What varies is the runtime dependency, and rather than assume Android, the plugin registers callbacks that fire only when a given base plugin is applied:

pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
    dependencies {
        "implementation"(libs.findLibrary("hilt.core").get())
    }
}

pluginManager.withPlugin("com.android.base") {
    apply(plugin = "dagger.hilt.android.plugin")
    dependencies {
        "implementation"(libs.findLibrary("hilt.android").get())
    }
}

pluginManager.withPlugin(id) { } runs its block if that plugin is already applied, or later when it becomes applied, and never runs it otherwise. A pure JVM module carries org.jetbrains.kotlin.jvm, so it receives hilt.core. An Android module carries com.android.base, the common ancestor of the application and library plugins, so it receives the Hilt Gradle plugin and hilt.android. The same nowinandroid.hilt alias does the right thing in both worlds, and the module that applied it never has to say which branch it wants. The plugin does not always add the Android artifacts; it adds whichever set matches what is already there.

Configuring two extensions: the Room plugin

Persistence follows the same apply then configure shape, but it configures two different extensions registered by two different plugins. nowinandroid.android.room applies Room and KSP, then configures each in turn:

class AndroidRoomConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "androidx.room")
            apply(plugin = "com.google.devtools.ksp")

            extensions.configure<KspExtension> {
                arg("room.generateKotlin", "true")
            }
            extensions.configure<RoomExtension> {
                schemaDirectory("$projectDir/schemas")
            }
            // ...
        }
    }
}

KspExtension comes from the KSP plugin and RoomExtension from the Room plugin, and both are configured only after their owning plugins are applied. The plugin tells KSP to generate Kotlin and tells Room where to write schema files. It then adds the Room dependencies:

dependencies {
    "implementation"(libs.findLibrary("room.runtime").get())
    "implementation"(libs.findLibrary("room.ktx").get())
    "ksp"(libs.findLibrary("room.compiler").get())
}

A module that needs a database now applies one plugin instead of repeating the runtime, ktx, and compiler coordinates plus the schema directory in every build file.

Composition and the api/impl split

Now in Android splits most features into two modules: an api module holding the public contract and an impl module holding the implementation. Each has its own convention plugin, and the pair shows composition at its clearest. The api plugin is deliberately thin:

class AndroidFeatureApiConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "nowinandroid.android.library")
            apply(plugin = "org.jetbrains.kotlin.plugin.serialization")

            dependencies {
                "api"(project(":core:navigation"))
            }
        }
    }
}

An api module is a library plus serialization plus a dependency on :core:navigation, nothing more. No Compose, no Hilt, no UI stack. It compiles fast and is safe for other features to depend on. Notice that it applies nowinandroid.android.library, so it inherits the entire library convention for free rather than restating it.

The impl plugin composes the full stack:

class AndroidFeatureImplConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "nowinandroid.android.library")
            apply(plugin = "nowinandroid.hilt")

            extensions.configure<LibraryExtension> {
                testOptions.animationsDisabled = true
                configureGradleManagedDevices(this)
            }
            // ...
        }
    }
}

It applies both the library plugin and the Hilt plugin, then injects the dependency set every feature needs:

dependencies {
    "implementation"(project(":core:ui"))
    "implementation"(project(":core:designsystem"))
    "implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
    "implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
    "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModelCompose").get())
    "implementation"(libs.findLibrary("androidx.navigation3.runtime").get())
    "implementation"(libs.findLibrary("androidx.tracing.ktx").get())
    "androidTestImplementation"(libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}

Every feature's impl gets :core:ui, :core:designsystem, the lifecycle Compose integrations, the nav3 runtime, and tracing without repeating any of them. The reason to split rather than ship one module is the dependency graph: consumers depend on another feature's api, never its impl, so changing an implementation does not force downstream features to recompile. The api is a Compose free contract that compiles quickly, the impl is the heavy module behind it, and the boundary keeps the build shallow. This split is the norm across the sample, though not every feature ships both halves; :feature:settings, for instance, is impl only.

The payoff: two aliases instead of sixty lines

With the plugins in place, a real module build file shrinks to its actual content. Here is core/data/build.gradle.kts:

plugins {
    alias(libs.plugins.nowinandroid.android.library)
    alias(libs.plugins.nowinandroid.android.library.jacoco)
    alias(libs.plugins.nowinandroid.hilt)
    id("kotlinx-serialization")
}

android {
    namespace = "com.google.samples.apps.nowinandroid.core.data"
    testOptions.unitTests.isIncludeAndroidResources = true
}

dependencies {
    api(projects.core.common)
    api(projects.core.database)
    // ...
}

Three convention plugin aliases, one namespace, and the module's real dependencies. There is no SDK, no Kotlin option, no flavor, no test runner, because all of it lives in the plugins. The android {} block holds the one thing that is genuinely per module, the namespace.

The feature split reads the same way. The impl module at feature/foryou/impl/build.gradle.kts composes its convention plugins:

plugins {
    alias(libs.plugins.nowinandroid.android.feature.impl)
    alias(libs.plugins.nowinandroid.android.library.compose)
    alias(libs.plugins.roborazzi)
    alias(libs.plugins.navgraph)
}

The matching api module at feature/foryou/api/build.gradle.kts is a single alias plus its contract:

plugins {
    alias(libs.plugins.nowinandroid.android.feature.api)
}
android { namespace = "com.google.samples.apps.nowinandroid.feature.foryou.api" }
dependencies { api(projects.core.navigation) }

That one alias, nowinandroid.android.feature.api, transitively applies nowinandroid.android.library, which applies com.android.library and nowinandroid.android.lint and runs configureKotlinAndroid and configureFlavors. On the impl side, the two Now in Android aliases expand into roughly six applied plugins and the complete standard feature configuration. Two lines carry what would otherwise be sixty.

The machinery: build-logic as an included build

So far the plugin ids have been treated as though they simply exist. The question is how nowinandroid.android.library becomes resolvable from thirty plus modules with no maven coordinate anywhere. The answer starts in the root settings.gradle.kts:

pluginManagement {
    includeBuild("build-logic")
    repositories {
        mavenLocal()
        google { /* content filters */ }
        mavenCentral()
        gradlePluginPortal()
    }
}

build-logic is a separate Gradle build with its own settings file that names itself and includes a :convention module. includeBuild("build-logic") pulls it in, and because it sits inside pluginManagement, Gradle builds it before the main build and exposes any plugin ids it publishes to every project, resolvable by id. This is the difference from buildSrc: an included build is an independently buildable project whose changes do not invalidate the entire main build, and its plugins are addressed by id rather than sitting on a global classpath.

Registering a binary plugin

Inside that included build, the :convention module is where the plugins are compiled and registered. Its own build file applies kotlin-dsl and depends on the plugins it configures:

plugins {
    `kotlin-dsl`
    alias(libs.plugins.android.lint)
}
group = "com.google.samples.apps.nowinandroid.buildlogic"

dependencies {
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.compose.gradlePlugin)
    compileOnly(libs.kotlin.gradlePlugin)
    compileOnly(libs.ksp.gradlePlugin)
    compileOnly(libs.room.gradlePlugin)
    // ...
}

The kotlin-dsl plugin lets this module compile Gradle Kotlin DSL and produce plugins. The dependencies are compileOnly on purpose. The convention plugins compile against extension types like ApplicationExtension, LibraryExtension, KspExtension, and RoomExtension, so they need those types at compile time, but they must not put AGP or the Kotlin plugin on a consuming module's runtime classpath. The consumer supplies the real plugin itself, pinned by the root build file. Marking these implementation would be wrong; only the lint checks artifact (on the lintChecks configuration) and a single genuine runtime piece, truth, are not compileOnly. These are binary plugins, compiled classes, not precompiled script plugins and not buildSrc.

The same build file registers each plugin, binding an id to the class that implements it:

gradlePlugin {
    plugins {
        register("androidLibrary") {
            id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        register("hilt") {
            id = libs.plugins.nowinandroid.hilt.get().pluginId
            implementationClass = "HiltConventionPlugin"
        }
        // ...
    }
}

Each register block binds an id string to a fully qualified class name. When Gradle resolves the id nowinandroid.android.library, this table tells it to instantiate AndroidLibraryConventionPlugin and call apply(project). The id is not hard coded as a literal; it is read from the version catalog with libs.plugins.nowinandroid.android.library.asProvider().get().pluginId. That is the handshake: the id registered here is guaranteed to equal the alias declared in the catalog, because both come from the same source.

The version catalog handshake

The last link is how the alias in a build file matches the id in the registration. Both pass through gradle/libs.versions.toml, where the [plugins] section declares each convention plugin:

[plugins]
nowinandroid-android-library = { id = "nowinandroid.android.library" }
nowinandroid-android-library-compose = { id = "nowinandroid.android.library.compose" }
nowinandroid-hilt = { id = "nowinandroid.hilt" }
nowinandroid-android-feature-impl = { id = "nowinandroid.android.feature.impl" }

There are three names for one thing here, and keeping them apart removes most of the confusion around version catalogs. The catalog alias uses dashes: nowinandroid-android-library. The generated accessor uses dots, because Gradle turns each dash into a dot: libs.plugins.nowinandroid.android.library. The plugin id string is the value of the id key: "nowinandroid.android.library". The accessor path and the id string look identical only because Now in Android chose them to match. They are independent; the accessor path comes from the alias name, and the id string comes from the TOML value.

One detail in the registration needs the asProvider() call. When an alias is also the prefix of other aliases, its generated accessor becomes an intermediate group node rather than a leaf:

id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
id = libs.plugins.nowinandroid.hilt.get().pluginId

nowinandroid-android-library is a prefix of nowinandroid-android-library-compose and nowinandroid-android-library-jacoco, so libs.plugins.nowinandroid.android.library is a group holding those children, and reaching the leaf plugin at that node requires .asProvider(). A leaf alias with no children, such as nowinandroid-hilt, uses .get() directly. That is exactly why the registration mixes the two forms.

Reading the catalog from plugin code

One asymmetry runs through every plugin: how they read the catalog. A build script receives a generated libs extension for free, but plugin classes do not, so they read the catalog through a hand written accessor:

val Project.libs
    get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

This property resolves the VersionCatalogsExtension and returns the catalog named "libs". With it, plugin code calls libs.findLibrary("kotlin.test").get(). Note that findLibrary takes dot separated names, so the catalog alias androidx-tracing-ktx is looked up as "androidx.tracing.ktx". This is why every dependency inside a convention plugin appears as libs.findLibrary("...").get() instead of the libs.androidx.tracing.ktx you would write in a build script. The two libs are not the same object, and only the build script version is generated.

Why this beats subprojects and apply from

Return to the naive approaches. subprojects {} and allprojects {} configure from the root, which means they run eagerly, force every subproject into one shape, and reach across project boundaries in a way that undermines configuration avoidance and project isolation. They cannot express that the app module is an application while a core module is a library and a util module is plain JVM. Convention plugins are opt in per module: a module applies exactly the plugins it needs, the configuration is typed Kotlin the IDE can navigate, and nothing configures another project from the root.

Now in Android is actively moving away from configuring across project boundaries, and its own root plugin shows it. RootPlugin guards its one remaining use of subprojects {} behind a feature check:

abstract class RootPlugin : Plugin<Project> {
    @get:Inject abstract val buildFeatures: BuildFeatures

    override fun apply(target: Project) {
        require(target.path == ":")
        if (!buildFeatures.isIsolatedProjectsEnabled()) {
            target.subprojects { configureGraphTasks() }
        }
        target.configureSpotlessForRootProject()
    }
}

RootPlugin applies only at the root, enforced by require(target.path == ":"), and it reaches into subprojects only when Isolated Projects is off. Under Isolated Projects, where reaching across projects is forbidden, it skips that path entirely; the module graph tasks are only registered when Isolated Projects is off. The convention plugin model is compatible with that isolation precisely because each plugin configures only its own project.

The other tempting shortcut is apply from: "shared.gradle", pulling a shared script into each build file. An applied script is untyped, unversioned, shares no compiled code, receives no IDE completion, and cannot declare extensions. A convention plugin is a compiled class with real types, it is versioned alongside the build, the IDE navigates into it, and it composes with other plugins by id. That is the gap between pasting a script and applying a plugin.

One last piece pins the classpath together. The root build.gradle.kts declares every third party plugin with apply false:

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.compose) apply false
    alias(libs.plugins.kotlin.jvm) apply false
    alias(libs.plugins.hilt) apply false
    // ...
    alias(libs.plugins.nowinandroid.root)
}

apply false puts each plugin on the build's classpath without applying it anywhere, which pins one version of AGP, Kotlin, Hilt, and the rest for every module. The convention plugins then apply those pinned plugins by id, so no module can drift onto a different AGP version. Only nowinandroid.root is actually applied, and only at the root, because RootPlugin requires the root path.

Conclusion

The practical shift is small to write and large in effect. A new module starts with plugins { alias(...) } and inherits a configuration that lives in exactly one place, so bumping compileSdk or swapping the test runner is a one line edit that every module picks up at once. The habit that follows is simple: the moment a build block starts to reappear in a second module, move it into a plugin instead of copying it, and reach for the api and impl split as soon as feature compilation starts to drag.

What makes this more than deduplication is the shift in how you think about a build. It stops being a pile of settings you paste and becomes a small program you compose, where apply makes capabilities exist, configure tunes them, and withPlugin lets one plugin react to another instead of branching on a guess. Once a convention plugin reads as what it is, an ordinary Plugin<Project>, the same type the Android Gradle Plugin itself is, the build graph turns into something you design on purpose rather than something that quietly accretes.

As always, happy coding!

Jaewoong (skydoves)