The previous post described what Nexus is and why the architecture is designed the way it is. This one is about what happened before any of that architecture could be written — setting up the build system.

Sixteen Gradle modules. Convention plugins. AGP 9.2. Kotlin 2.2.10. KSP. Every one of those things has opinions about the others, and not all of those opinions are compatible.

Here's the log.


Why Convention Plugins Instead of buildSrc

The standard path for sharing build logic across modules is buildSrc — a special directory that Gradle treats as a pre-compiled build classpath. It works. The problem is that buildSrc invalidates the entire build configuration cache whenever any file inside it changes. With 16 modules, a one-line change to a shared build utility rebuilds everything.

Convention plugins use includeBuild instead. The build logic lives in a separate Gradle build at build-logic/, included in the root settings.gradle.kts. Changes to build-logic only invalidate modules that actually apply the changed plugin — which, in practice, is far fewer than all of them.

The performance difference matters less on a laptop than on CI. On a cold CI run against a feature branch that touched one plugin, the cache stays warm for 14 of the 16 modules. That's the reason.


The Structure

build-logic/
├── settings.gradle.kts          ← easy to forget, hard to debug without
└── convention/
    ├── build.gradle.kts
    └── src/main/kotlin/
        ├── ext/
        │   ├── VersionCatalogExt.kt
        │   └── CommonExtensions.kt
        ├── NexusAndroidApplicationPlugin.kt
        ├── NexusAndroidLibraryPlugin.kt
        ├── NexusAndroidComposePlugin.kt
        ├── NexusAndroidFeaturePlugin.kt
        ├── NexusAndroidTestPlugin.kt
        └── NexusHiltPlugin.kt

Six plugins, each with a focused job:

Plugin Applies to Responsibilities
nexus.android.application :app AGP application config, signing, build types, BuildConfig URLs
nexus.android.library core modules AGP library config, ProGuard baseline
nexus.android.compose anything using Compose Kotlin Compose plugin, Compose BOM, UI tooling
nexus.android.feature feature modules library + compose + hilt + nav deps
nexus.hilt anything using Hilt Hilt plugin, KSP, hilt-compiler
nexus.android.test :benchmark AGP test plugin, MacroBenchmark deps

The idea: a feature module's entire build file is two lines.

plugins {
    alias(libs.plugins.nexus.android.feature)
}

android {
    namespace = "in.singhangad.nexus.feature.board"
}

Everything else — Compose dependencies, Hilt wiring, navigation deps, compile SDK, ProGuard — is inherited.


The Version Catalog

All dependency versions and coordinates live in gradle/libs.versions.toml. The part that tripped me up early on was the distinction between [libraries] and [plugins].

The convention plugins themselves need build-time access to the AGP, Kotlin, and KSP Gradle plugin JARs as a compile classpath — not to apply them in another project, but so the Kotlin code in build-logic can reference their extension types. Those go in [libraries]:

[libraries]
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
kotlin-gradlePlugin  = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
ksp-gradlePlugin     = { group = "com.google.devtools.ksp", name = "symbol-processing-gradle-plugin", version.ref = "ksp" }

Then in build-logic/convention/build.gradle.kts:

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

The mistake is putting these in [plugins] — which expects id and version.ref, not group and name. They are libraries. Gradle will tell you this with a confusing parse error that doesn't make it obvious what you did wrong.


The Six Breaks

1. The missing build-logic/settings.gradle.kts

The first error was a blank stare from Gradle: org.gradle.api.Plugin not found. No plugin class resolved. Nothing.

The cause: build-logic is an included Gradle build, and every Gradle build needs a settings.gradle.kts. Without it, the convention subproject doesn't exist as far as Gradle is concerned. The plugins never compile.

// build-logic/settings.gradle.kts
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
    versionCatalogs {
        create("libs") { from(files("../gradle/libs.versions.toml")) }
    }
}

rootProject.name = "build-logic"
include(":convention")

The @Suppress("UnstableApiUsage") is needed because dependencyResolutionManagement inside an included build is still annotated @Incubating. The API has been stable in practice for years, but the annotation hasn't been removed.

2. includeBuild in the wrong block

The root settings.gradle.kts needs includeBuild("build-logic") inside pluginManagement, not at the top level. Outside pluginManagement, Gradle doesn't recognise the included build as a plugin provider. Inside, it does.

// settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")   // ← here, not outside
    repositories { ... }
}

3. AGP 9.x: CommonExtension lost its type parameters

Convention plugins commonly use a configureKotlinAndroid(CommonExtension<*, *, *, *, *, *>) helper that sets compileSdk, compileOptions, and testOptions once for both application and library modules. AGP 9.x removed the generic parameters from CommonExtension. The type is now just CommonExtension.

That's the easy part. The harder part: AGP 9.x also stripped defaultConfig, compileOptions, and testOptions off CommonExtension entirely. They only exist on the concrete subtypes — ApplicationExtension and LibraryExtension.

The fix is two overloads:

// ext/CommonExtensions.kt
internal fun Project.configureKotlinAndroid(ext: ApplicationExtension) {
    with(ext) {
        compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
        defaultConfig.minSdk  = libs.findVersion("minSdk").get().toString().toInt()
        defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_11
            targetCompatibility = JavaVersion.VERSION_11
        }
        testOptions.unitTests.isIncludeAndroidResources = true
        configureKotlinTasks()
    }
}

internal fun Project.configureKotlinAndroid(ext: LibraryExtension) {
    with(ext) {
        compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
        defaultConfig.minSdk = libs.findVersion("minSdk").get().toString().toInt()
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_11
            targetCompatibility = JavaVersion.VERSION_11
        }
        testOptions.unitTests.isIncludeAndroidResources = true
        configureKotlinTasks()
    }
}

4. AGP 9.x auto-applies kotlin.android

AGP 9.x includes Kotlin as a built-in component and auto-applies org.jetbrains.kotlin.android when it detects an Android module. If your convention plugin also calls apply("org.jetbrains.kotlin.android"), Gradle throws:

Cannot add extension with name 'kotlin', as there is an extension already registered with that name.

The fix: don't apply it. AGP handles it. The only Kotlin plugin that convention plugins should apply manually is org.jetbrains.kotlin.plugin.compose, which is separate and not auto-applied.

5. kotlinOptions is deprecated

The old way:

tasks.withType<KotlinCompile>().configureEach {
    kotlinOptions { jvmTarget = "11" }
}

kotlinOptions was deprecated in Kotlin 2.0 and the deprecation warning is now a warning in 2.2.x. The replacement uses the compilerOptions DSL:

private fun Project.configureKotlinTasks() {
    tasks.withType<KotlinCompile>().configureEach {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
            freeCompilerArgs.addAll(
                "-opt-in=kotlin.RequiresOptIn",
                "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
            )
        }
    }
}

6. KSP and the Built-in Kotlin conflict

After everything else was green, the build threw this at the first module that applied the Hilt plugin:

Using kotlin.sourceSets DSL to add Kotlin sources is not allowed with built-in Kotlin.
Kotlin source set 'debug' contains:
  [.../build/generated/ksp/debug/kotlin,
   .../build/generated/ksp/debug/java]
Solution: Use android.sourceSets DSL instead.

KSP registers its generated sources using kotlin.sourceSets, which conflicts with AGP 9.x's Built-in Kotlin that expects android.sourceSets. The fix is a single line in gradle.properties:

android.disallowKotlinSourceSets=false

This suppresses the error while KSP adds native support for AGP 9.x's source set API. It's a workaround — when KSP ships the native support, the property comes out. Worth commenting in the file to explain why it's there.


The Compose Plugin Needs withPlugin

The Compose convention plugin — nexus.android.compose — is applied to both app and library modules. It needs to configure the android {} extension. But at the time the Compose plugin runs, the Android plugin might not have been applied yet (plugin order in Gradle is not guaranteed).

The safe pattern uses pluginManager.withPlugin():

class NexusAndroidComposePlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("org.jetbrains.kotlin.plugin.compose")

            // configure android{} only after the Android plugin is applied,
            // regardless of plugin application order
            pluginManager.withPlugin("com.android.application") {
                extensions.configure<ApplicationExtension> { configureCompose(this) }
            }
            pluginManager.withPlugin("com.android.library") {
                extensions.configure<LibraryExtension> { configureCompose(this) }
            }
        }
    }

    private fun Project.configureCompose(ext: CommonExtension) {
        ext.buildFeatures.compose = true
        dependencies {
            val bom = platform(libs.findLibrary("androidx-compose-bom").get())
            add("implementation", bom)
            add("implementation", libs.findLibrary("androidx-compose-material3").get())
            add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get())
            add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
            add("debugImplementation", libs.findLibrary("androidx-compose-ui-test-manifest").get())
        }
    }
}

Without withPlugin(), configuring a LibraryExtension before com.android.library is applied throws a missing extension error. The lambda runs lazily — only when the named plugin actually lands on the project.


What the Application Plugin Looks Like

The full application convention plugin, trimmed to the structure:

class NexusAndroidApplicationPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.application")
                apply("nexus.android.compose")
                apply("nexus.hilt")
                // do NOT apply org.jetbrains.kotlin.android — AGP 9.x does it
            }

            val localProps = Properties().apply {
                val f = rootProject.file("local.properties")
                if (f.exists()) load(f.inputStream())
            }

            extensions.configure<ApplicationExtension> {
                configureKotlinAndroid(this)

                defaultConfig {
                    minSdk      = libs.findVersion("minSdk").get().toString().toInt()
                    targetSdk   = libs.findVersion("targetSdk").get().toString().toInt()
                    versionCode = libs.findVersion("versionCode").get().toString().toInt()
                    versionName = libs.findVersion("versionName").get().toString()
                    // applicationId intentionally NOT set here — it's app-specific,
                    // not a build convention. Set in app/build.gradle.kts.
                }

                buildTypes {
                    debug {
                        applicationIdSuffix = ".debug"
                        buildConfigField("String", "API_BASE_URL",
                            "\"${localProps["DEBUG_API_URL"] ?: "http://10.0.2.2:3000/api/v1"}\"")
                    }
                    release {
                        isMinifyEnabled   = true
                        isShrinkResources = true
                        signingConfig     = signingConfigs.getByName("release")
                        buildConfigField("String", "API_BASE_URL",
                            "\"${localProps["PROD_API_URL"] ?: ""}\"")
                        manifestPlaceholders["enableFlagSecure"] = "true"
                    }
                }

                buildFeatures { buildConfig = true }
            }
        }
    }
}

One thing that's easy to miss: applicationId is deliberately absent from the plugin. Convention plugins are meant to be reusable across projects — baking in in.singhangad.nexus would make this unusable anywhere else. The application ID lives in app/build.gradle.kts, which is the one file that's allowed to be app-specific.


What's Left Out of the Build

A few things that will come later:

Baseline Profiles. The :benchmark module exists and the nexus.android.test convention plugin is wired, but the BaselineProfileRule tests aren't written yet. Profiles will be generated once the first set of screens is stable.

R8 rules. The proguard-rules.pro files are mostly empty stubs. Real rules only get written after the first release build crashes — trying to write them speculatively before the code exists usually produces rules that are either too broad (strip nothing) or wrong (strip the wrong things).

FCM / google-services.json. Firebase isn't initialised yet. The .gitignore entry for google-services.json is in place and a local.properties.example documents what the file needs to contain. The plugin will be wired when the notifications feature module gets built.


Where It Stands

The build is green. All 16 modules compile. The convention plugins work, the version catalog is the single source of truth, and the configuration cache is enabled.

The scaffolding exists to support the architecture described in the previous post. The interesting work starts now.


support

Found this useful? You can buy me a coffee.

☕ buy me a coffee