Background
My project originally used a single-module Gradle build. Everything lived in the root build.gradle.kts
, with dependencies and plugins managed through a libs.versions.toml
version catalog. This setup was simple and worked fine.
As the codebase grew, I wanted to:
- Split the project into multiple modules.
- Centralize quality tools (Spotless, SpotBugs, Error Prone + NullAway, JSpecify, JUnit).
- Keep the build DRY and maintainable.
What I expected to be a straightforward migration turned into a much more painful process than anticipated.
First Attempt: subprojects {}
My first approach, following typical examples (including ChatGPT suggestions), was to use subprojects {}
in the root build file:
import net.ltgt.gradle.errorprone.errorprone
repositories {
mavenCentral()
}
plugins {
java
alias(libs.plugins.io.freefair.lombok)
// https://github.com/diffplug/spotless/tree/main?tab=readme-ov-file
alias(libs.plugins.com.diffplug.spotless)
// https://spotbugs.readthedocs.io/en/latest/gradle.html#configure-gradle-plugin
alias(libs.plugins.com.github.spotbugs)
// https://github.com/tbroyer/gradle-errorprone-plugin
alias(libs.plugins.net.ltgt.errorprone)
}
subprojects {
// Core Java + quality plugins for ALL modules
apply(plugin = libs.plugins.com.diffplug.spotless.get().pluginId)
apply(plugin = libs.plugins.com.github.spotbugs.get().pluginId)
apply(plugin = libs.plugins.net.ltgt.errorprone.get().pluginId)
apply(plugin = libs.plugins.io.freefair.lombok.get().pluginId)
repositories {
mavenCentral()
}
dependencies {
// JSpecify: annotations only; don't ship them at runtime
compileOnly(libs.org.jspecify.jspecify)
testCompileOnly(libs.org.jspecify.jspecify)
// Error Prone + NullAway for ALL modules
errorprone(libs.com.google.errorprone.error.prone.core)
errorprone(libs.com.uber.nullaway.nullaway)
// JUnit (shared default)
testImplementation(platform(libs.org.junit.junit.bom))
testImplementation(libs.org.junit.jupiter.junit.jupiter)
testRuntimeOnly(libs.org.junit.platform.junit.platform.launcher)
testImplementation(libs.org.assertj.assertj.core)
}
java {
sourceCompatibility = JavaVersion.VERSION_24
targetCompatibility = JavaVersion.VERSION_24
modularity.inferModulePath = true
}
// https://github.com/diffplug/spotless/tree/main/plugin-gradle#palantir-java-format
spotless {
java {
importOrder()
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
formatAnnotations()
palantirJavaFormat().formatJavadoc(true)
}
}
// https://spotbugs-gradle-plugin.netlify.app/com/github/spotbugs/snom/spotbugsextension
spotbugs {
toolVersion = "4.9.4"
effort = com.github.spotbugs.snom.Effort.MAX
reportLevel = com.github.spotbugs.snom.Confidence.LOW
showProgress = false // TODO: enable before commiting
ignoreFailures = true // TODO: disable before commiting
reportsDir = file("${layout.buildDirectory}/reports/spotbugs")
projectName = name
release = version.toString()
maxHeapSize = "512m"
jvmArgs = listOf("-Duser.language=en")
}
// Error Prone + NullAway for all Java compilations
tasks.withType<JavaCompile>().configureEach {
options.apply {
encoding = "UTF-8"
errorprone {
// Do NOT disable all checks — keep the defaults and enable NullAway
check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR)
option("NullAway:JSpecifyMode", "true")
option("NullAway:OnlyNullMarked", "true")
}
// Example: make tests less strict (disable NullAway for test sources only)
if (name.contains("test", ignoreCase = true)) {
errorprone.disable("NullAway")
}
}
}
tasks.withType<Javadoc> {
options.encoding = "UTF-8"
}
tasks.test {
useJUnitPlatform()
}
// Keep code formatted before build
tasks.build {
dependsOn(tasks.spotlessApply)
}
}
But running this immediately failed with:
Build file 'C:\..\build.gradle.kts' line: 25
Extension with name 'libs' does not exist. Currently registered extension names: [ext]
Even though libs.versions.toml
was still there. After going back and forth with ChatGPT and trying fixes like adding:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { mavenCentral() }
versionCatalogs {
create("libs") {
from(files("gradle/libs.versions.toml")) // points to your TOML
}
}
}
…to settings.gradle.kts
, nothing worked. At that point I gave up on ChatGPT, turned to Google, and eventually found Sharing Build Logic in BuildSrc. That seemed to be a better fit, so I restructured the project as described in the docs.
Second Attempt: buildSrc
I set up buildSrc
and followed the official instructions, assuming things would just work. My example-common.gradle.kts
looked like this:
group = "com.example"
version = "1.3.0-SNAPSHOT"
description = "realExample"
repositories {
mavenCentral()
}
plugins {
java
alias(libs.plugins.io.freefair.lombok)
// https://github.com/diffplug/spotless/tree/main?tab=readme-ov-file
alias(libs.plugins.com.diffplug.spotless)
// https://spotbugs.readthedocs.io/en/latest/gradle.html#configure-gradle-plugin
alias(libs.plugins.com.github.spotbugs)
// https://github.com/tbroyer/gradle-errorprone-plugin
alias(libs.plugins.net.ltgt.errorprone)
}
dependencies {
// JSpecify: annotations only; don't ship them at runtime
compileOnly(libs.org.jspecify.jspecify)
testCompileOnly(libs.org.jspecify.jspecify)
// Error Prone + NullAway for ALL modules
errorprone(libs.com.google.errorprone.error.prone.core)
errorprone(libs.com.uber.nullaway.nullaway)
// JUnit (shared default)
testImplementation(platform(libs.org.junit.junit.bom))
testImplementation(libs.org.junit.jupiter.junit.jupiter)
testRuntimeOnly(libs.org.junit.platform.junit.platform.launcher)
testImplementation(libs.org.assertj.assertj.core)
}
java {
sourceCompatibility = JavaVersion.VERSION_24
targetCompatibility = JavaVersion.VERSION_24
modularity.inferModulePath = true
}
// https://github.com/diffplug/spotless/tree/main/plugin-gradle#palantir-java-format
spotless {
java {
importOrder()
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
formatAnnotations()
palantirJavaFormat().formatJavadoc(true)
}
}
// https://spotbugs-gradle-plugin.netlify.app/com/github/spotbugs/snom/spotbugsextension
spotbugs {
toolVersion = "4.9.4"
effort = com.github.spotbugs.snom.Effort.MAX
reportLevel = com.github.spotbugs.snom.Confidence.LOW
showProgress = false // TODO: enable before commiting
ignoreFailures = true // TODO: disable before commiting
reportsDir = file("${layout.buildDirectory}/reports/spotbugs")
projectName = name
release = version.toString()
maxHeapSize = "512m"
jvmArgs = listOf("-Duser.language=en")
}
// Error Prone + NullAway for all Java compilations
tasks.withType<JavaCompile>().configureEach {
options.apply {
encoding = "UTF-8"
errorprone {
// Do NOT disable all checks — keep the defaults and enable NullAway
check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR)
option("NullAway:JSpecifyMode", "true")
option("NullAway:OnlyNullMarked", "true")
}
// Example: make tests less strict (disable NullAway for test sources only)
if (name.contains("test", ignoreCase = true)) {
errorprone.disable("NullAway")
}
}
}
tasks.withType<Javadoc> {
options.encoding = "UTF-8"
}
tasks.test {
useJUnitPlatform()
}
// Keep code formatted before build
tasks.build {
dependsOn(tasks.spotlessApply)
}
But this led to new errors:
e: file:///C:/../buildSrc/build/kotlin-dsl/plugins-blocks/extracted/example-common.gradle.kts:11:11 Unresolved reference 'libs'.
e: file:///C:/../buildSrc/build/kotlin-dsl/plugins-blocks/extracted/example-common.gradle.kts:11:16 'fun Project.plugins(block: PluginDependenciesSpec.() -> Unit): Nothing' is deprecated. The plugins {} block must not be used here. If you need to apply a plugin imperatively, please use apply<PluginType>() or apply(plugin = "id") instead.
So back to Google again—this time about version catalogs. I came across Version Catalogs and tried adjusting settings.gradle.kts
to include:
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
Even after all this effort, I’m still running into errors.
It’s 4 a.m. now, and I’m completely exhausted. This process should be so much easier. All I want is to keep using the plugins from the version catalog in my convention plugin—without having to manually copy everything over or rely on the strange methods in the Using a catalog in buildSrc, which I don’t fully understand.