Convention Plugins & Sharing Build Logic
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon and the compiler ran on JDK 26; the
code is compiled for Java 21 with javac --release, so no toolchain was involved). Every example was run through the
wrapper in scratch copies of the tidepool build, once with Kotlin DSL files and once with Groovy DSL files; the output is the same
in both apart from the timings and the names of the tasks that compile the plugins, and the page says where the two differ. The
scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project
commits is
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
The commands use --console=plain. In the listings the blank lines, the Try: hints of failures and the "For more information"
line that follows the settings-plugin error are left out, the scratch directory is shown as /work/tidepool, and the tasks of the
three tidepool projects are collapsed to the single line (tidepool project tasks omitted) where they are not the point, because
Gradle runs unrelated tasks in no fixed order. The project’s gradle.properties is
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m
so every run used the configuration cache, and every example on this page is compatible with it (the convention plugins do not read
project inside task actions). The Metaspace limit is there because one run of the Groovy build with the default limits ran the daemon out of Metaspace and
Gradle printed a warning. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and the
release notes and are labelled "not run here".
A build with more than one project soon has the same dozen lines in every project: the Java release, the test framework, the
group and version, the compiler flags. This page is about the places where Gradle lets you write those lines once, and about the
form that the Gradle manual recommends for them, the convention plugin. The plugin vocabulary (core, community and local
plugins, the plugins block) is on
Plugins Overview; plugins written as classes, with their
own tests and publication, are on
Binary Plugins and
Testing & Publishing Plugins; the
project structure that makes sharing necessary is on
Multi Project Builds, and the mechanism behind
includeBuild is on Composite Builds.
The problem: copied or injected configuration
The tidepool build of Settings & Build Scripts
has three projects (tidepool-model, tidepool-engine and tidepool-cli), a version catalog and repositories declared once in
the settings file. Every project needs the same Java release, JUnit and useJUnitPlatform(). The traditional way to avoid copying
those lines is to inject them from the root project:
plugins {
base
}
allprojects {
group = "io.tidepool"
version = "1.0.0"
}
subprojects {
apply(plugin = "java")
tasks.withType<JavaCompile>().configureEach {
options.release = 21
}
dependencies {
"testImplementation"(platform(rootProject.libs.junit.bom))
"testImplementation"("org.junit.jupiter:junit-jupiter")
"testRuntimeOnly"("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
}
plugins {
id 'base'
}
allprojects {
group = 'io.tidepool'
version = '1.0.0'
}
subprojects {
apply plugin: 'java'
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
dependencies {
testImplementation platform(rootProject.libs.junit.bom)
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
}
The three project scripts then contain only what is specific to them, for example a plugins block that applies only the java-library plugin in the model project. The
catalog is the one used throughout the section:
[versions]
commons-csv = "1.14.1"
junit = "6.1.3"
picocli = "4.7.7"
[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
This works (both DSLs built the three projects and ran the JUnit test of the model project), and it is exactly what the Gradle
manual calls the improper way. The rootProject. in front of libs is not decoration. Written as plain libs.junit.bom the Kotlin DSL
script fails, because at the moment the root script configures the subprojects they do not have the libs extension yet:
$ ./gradlew build
FAILURE: Build failed with an exception.
* Where:
Build file '/work/tidepool/build.gradle.kts' line: 18
* What went wrong:
Extension with name 'libs' does not exist. Currently registered extension names: [ext, base, sourceSets, reporting, javaToolchains, java, testing]
The Groovy DSL version with plain libs.junit.bom succeeded, because Groovy resolves the name through the properties that a
subproject inherits from its parent. The two DSLs differ here, which is typical of injected configuration: what a line means depends on
the moment it runs and on which project it runs against. The Gradle manual lists the other costs of cross-project configuration:
-
it is not obvious from a project’s own build script, which says nothing about the
javaplugin or the release it was compiled for; -
it grows in complexity and becomes a burden;
-
it couples the configuration of projects to each other, which prevents optimisations such as configuration-on-demand.
Source: Gradle User Manual: Avoid cross-project configuration using subprojects and allprojects and Convention plugins versus cross-project configuration
The two uses of allprojects/subprojects that the manual recognises each have a better model. "If a subproject is of type X, configure
Y" is the same as applying an X-conventions plugin to that subproject. "Collect information from the subprojects of a certain type"
is modelled with outgoing configuration variants (see
Multi Project Builds). The book Gradle in Action
(Gradle 1.7), which this section cites, teaches multi-project builds with allprojects and subprojects; that style still runs, but
the current documentation steers to convention plugins.
Ways to share build logic
| Way | What it is | When it fits |
|---|---|---|
Copying the lines |
The same block in every build script. |
Two projects. Beyond that, the copies drift apart. |
|
Cross-project configuration, as above. |
Not recommended; use it only to understand old builds. |
Script plugin |
Another script pulled in with |
Old builds. The script has no id, no tests and no type-safe accessors, and it is evaluated against whatever applied it. |
Convention plugin in |
A precompiled script plugin in a directory that Gradle builds automatically and puts on every build script’s classpath. |
Small builds and prototypes; no settings change needed. |
Convention plugin in an included |
The same scripts in a separate build that |
The recommended location for build logic of any size. |
Binary plugin |
A |
Logic shared between builds, or logic that deserves types and tests; see Binary Plugins. |
A convention plugin is not a different kind of plugin. The manual defines it as a precompiled script plugin that normally
configures existing core and community plugins with your own defaults (the conventions), for example the Java release, and
that can also enforce project standards, create tasks and extensions and set dependencies. Applying it is one line in a project, and a
project that does not apply it is not touched. The word "convention" has an older, unrelated use that is easy to confuse with
this one: the Convention mechanism was removed in Gradle 9.0 (see
Versions, Compatibility & Release History).
What remains is the lazy-configuration meaning, the default value of a property set with
Property.convention(...), described below and on
Lazy Configuration & Providers.
Precompiled script plugins
A precompiled script plugin is a .gradle.kts (Kotlin DSL) or .gradle (Groovy DSL) script stored in the source set of a plugin
project: src/main/kotlin when that project applies the kotlin-dsl plugin, src/main/groovy when it applies
groovy-gradle-plugin. Gradle compiles the script into a real plugin class, packages it with a descriptor, and takes the plugin id from
the file name. A script is therefore both a build script (it has the plugins block and the DSL of a project) and a plugin that other
builds can apply by id.
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
}
plugins {
id 'groovy-gradle-plugin'
}
repositories {
gradlePluginPortal()
}
Both plugin ids are bundled with Gradle, so neither needs a version. The rules for the id and the kind of plugin are:
| File | Plugin id and type |
|---|---|
|
|
|
|
|
|
|
An init plugin ( |
The plugins block of a precompiled script plugin has two restrictions that a normal build script does not have. It cannot carry a
version, and it cannot say apply false. Third-party plugins that the script applies must be on the plugin project’s classpath, as an
implementation dependency in the build file of build-logic, and the version of that dependency is the plugin’s version. A plugin that
has to be on the classpath without being applied is declared the same way and left out of the plugins block.
Kotlin DSL scripts get type-safe accessors for what the plugins block applies: after java is applied, testImplementation(...)
and sourceSets are ordinary Kotlin names, and after an extension-providing plugin is applied there is an accessor for its extension.
Gradle generates them in the plugin build (the task generatePrecompiledScriptPluginAccessors in the listings below). A Groovy script has no
accessors and resolves the same names dynamically.
Step 1: the same logic in buildSrc
buildSrc is a directory with a name that Gradle recognises. When it exists at the root of the build, Gradle treats it as an included
build, compiles it before it evaluates any other script in the main build, and puts the result on the classpath of the build scripts of
the root project and the subprojects. There can be only one buildSrc, in the root directory, and no change to settings.gradle(.kts)
is needed. Moving the injected lines into one convention plugin there gives this layout:
tidepool
├── buildSrc
│ ├── build.gradle.kts (build.gradle for the Groovy DSL)
│ └── src/main/kotlin (src/main/groovy)
│ ├── io/tidepool/build/TidepoolJavaExtension.kt (.groovy)
│ ├── tidepool.java-conventions.gradle.kts (.gradle)
│ ├── tidepool.library-conventions.gradle.kts
│ └── tidepool.application-conventions.gradle.kts
├── gradle/libs.versions.toml
├── settings.gradle.kts (settings.gradle)
├── tidepool-model/build.gradle.kts
├── tidepool-engine/build.gradle.kts
└── tidepool-cli/build.gradle.kts
The files are the ones shown in the next section, with the same contents; the only differences are the directory (buildSrc instead of
build-logic) and the settings file, which has no pluginManagement block. The first build compiles buildSrc as a build of its
own, before the projects are configured:
$ ./gradlew build
Calculating task graph as no cached configuration is available for tasks: build
> Task :buildSrc:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :buildSrc:generateExternalPluginSpecBuilders
> Task :buildSrc:extractPrecompiledScriptPluginPlugins
> Task :buildSrc:generateScriptPluginAdapters
> Task :buildSrc:pluginDescriptors
> Task :buildSrc:processResources
> Task :buildSrc:compilePluginsBlocks
> Task :buildSrc:generatePrecompiledScriptPluginAccessors
> Task :buildSrc:compileKotlin
> Task :buildSrc:compileJava NO-SOURCE
> Task :buildSrc:compileGroovy NO-SOURCE
> Task :buildSrc:classes
> Task :buildSrc:jar
(tidepool project tasks omitted)
BUILD SUCCESSFUL in 5s
20 actionable tasks: 20 executed
Configuration cache entry stored.
(Kotlin DSL, run with --configuration-cache, compatible with it.) The Groovy DSL project ran the same build with the tasks of
groovy-gradle-plugin (:buildSrc:extractPluginRequests, :buildSrc:generatePluginAdapters, :buildSrc:compileGroovyPlugins and so
on) and 19 actionable tasks.
The manual lists what buildSrc is good for: no setup, automatic compilation and classpath inclusion, cleaner build scripts, tests for
the build logic like any other project, and an easy way to write plugins used only inside the build. It also states the cost: changes in
buildSrc invalidate the configuration phase. That is why the same manual recommends the next step for anything beyond a prototype.
Step 2: the same logic in an included build-logic build
The recommended location is an included build, conventionally called build-logic, a complete Gradle build in its own directory that the
main build includes in the pluginManagement block of its settings file. The final layout of the tidepool build has two such
builds: build-logic for the plugins that configure projects, and a second, minimal build-logic-settings that holds only
settings plugins, which the section on settings plugins below explains.
tidepool
├── build-logic
│ ├── settings.gradle.kts (settings.gradle)
│ ├── build.gradle.kts (build.gradle)
│ └── src/main/kotlin (src/main/groovy)
│ ├── io/tidepool/build/TidepoolJavaExtension.kt (.groovy)
│ ├── tidepool.java-conventions.gradle.kts (.gradle)
│ ├── tidepool.library-conventions.gradle.kts
│ └── tidepool.application-conventions.gradle.kts
├── build-logic-settings
│ ├── settings.gradle.kts (settings.gradle)
│ ├── build.gradle.kts (build.gradle)
│ └── src/main/kotlin (src/main/groovy)
│ ├── tidepool.repositories.settings.gradle.kts (.gradle)
│ └── tidepool.scans.settings.gradle.kts
├── gradle
│ ├── libs.versions.toml
│ └── wrapper/gradle-wrapper.properties
├── gradle.properties
├── settings.gradle.kts (settings.gradle)
├── tidepool-model/build.gradle.kts
├── tidepool-engine/build.gradle.kts
└── tidepool-cli/build.gradle.kts
The settings file includes both builds and applies the settings plugins from them. The main build has no root build.gradle.kts any
more: nothing is injected into the projects.
pluginManagement {
includeBuild("build-logic-settings")
includeBuild("build-logic")
}
plugins {
id("tidepool.repositories")
id("tidepool.scans")
}
rootProject.name = "tidepool"
include("tidepool-model", "tidepool-engine", "tidepool-cli")
pluginManagement {
includeBuild 'build-logic-settings'
includeBuild 'build-logic'
}
plugins {
id 'tidepool.repositories'
id 'tidepool.scans'
}
rootProject.name = 'tidepool'
include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
The settings file of build-logic itself only names the build:
rootProject.name = "build-logic"
rootProject.name = 'build-logic'
Its build file is the one shown in the section on precompiled script plugins. The three convention plugins follow. The first holds
what every Java project of the toolkit shares; the second and third compose it with the plugin that makes a project a library or an
application. A convention plugin can apply other convention plugins in its plugins block, which lets you build the shared logic from
small pieces:
import io.tidepool.build.TidepoolJavaExtension
plugins {
java
}
group = "io.tidepool"
version = "1.0.0"
val tidepoolJava = extensions.create<TidepoolJavaExtension>("tidepoolJava")
tidepoolJava.release.convention(21)
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
testImplementation(platform(libs.findLibrary("junit-bom").get()))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<JavaCompile>().configureEach {
options.release = tidepoolJava.release
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
import io.tidepool.build.TidepoolJavaExtension
plugins {
id 'java'
}
group = 'io.tidepool'
version = '1.0.0'
def tidepoolJava = extensions.create('tidepoolJava', TidepoolJavaExtension)
tidepoolJava.release.convention(21)
def libs = extensions.getByType(VersionCatalogsExtension).named('libs')
dependencies {
testImplementation platform(libs.findLibrary('junit-bom').get())
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile).configureEach {
options.release = tidepoolJava.release
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
package io.tidepool.build
import org.gradle.api.provider.Property
interface TidepoolJavaExtension {
/** The Java release the code is compiled for (javac --release). */
val release: Property<Int>
}
package io.tidepool.build
import org.gradle.api.provider.Property
interface TidepoolJavaExtension {
/** The Java release the code is compiled for (javac --release). */
Property<Integer> getRelease()
}
plugins {
id("tidepool.java-conventions")
`java-library`
}
plugins {
id("tidepool.java-conventions")
application
}
plugins {
id 'tidepool.java-conventions'
id 'java-library'
}
plugins {
id 'tidepool.java-conventions'
id 'application'
}
The project scripts now say only what is specific to them:
plugins {
id("tidepool.library-conventions")
}
plugins {
id("tidepool.library-conventions")
}
dependencies {
api(project(":tidepool-model"))
implementation(libs.commons.csv)
}
plugins {
id("tidepool.application-conventions")
}
dependencies {
implementation(project(":tidepool-engine"))
implementation(libs.picocli)
}
application {
mainClass = "io.tidepool.cli.Main"
}
plugins {
id 'tidepool.library-conventions'
}
plugins {
id 'tidepool.library-conventions'
}
dependencies {
api project(':tidepool-model')
implementation libs.commons.csv
}
plugins {
id 'tidepool.application-conventions'
}
dependencies {
implementation project(':tidepool-engine')
implementation libs.picocli
}
application {
mainClass = 'io.tidepool.cli.Main'
}
The structure of the toolkit is now readable from the plugin ids:
The first build compiles the two included builds, then the projects; the second build changes nothing and reuses the configuration cache:
$ ./gradlew build
Calculating task graph as no cached configuration is available for tasks: build
> Task :build-logic-settings:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :build-logic-settings:generateExternalPluginSpecBuilders
> Task :build-logic-settings:extractPrecompiledScriptPluginPlugins
> Task :build-logic-settings:generateScriptPluginAdapters
> Task :build-logic-settings:pluginDescriptors
> Task :build-logic-settings:processResources
> Task :build-logic-settings:compilePluginsBlocks
> Task :build-logic-settings:generatePrecompiledScriptPluginAccessors
> Task :build-logic-settings:compileKotlin
> Task :build-logic-settings:compileJava NO-SOURCE
> Task :build-logic-settings:classes
> Task :build-logic-settings:jar
> Task :build-logic:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :build-logic:generateExternalPluginSpecBuilders
> Task :build-logic:extractPrecompiledScriptPluginPlugins
> Task :build-logic:generateScriptPluginAdapters
> Task :build-logic:pluginDescriptors
> Task :build-logic:processResources
> Task :build-logic:compilePluginsBlocks
> Task :build-logic:generatePrecompiledScriptPluginAccessors
> Task :build-logic:compileKotlin
> Task :build-logic:compileJava NO-SOURCE
> Task :build-logic:classes
> Task :build-logic:jar
(tidepool project tasks omitted)
BUILD SUCCESSFUL in 7s
29 actionable tasks: 29 executed
Configuration cache entry stored.
$ ./gradlew build
> Task :tidepool-cli:assemble UP-TO-DATE
> Task :tidepool-cli:build UP-TO-DATE
BUILD SUCCESSFUL in 556ms
11 actionable tasks: 11 up-to-date
Configuration cache entry reused.
(Kotlin DSL; compatible with the configuration cache.) The Groovy DSL project printed the tasks of groovy-gradle-plugin for both
included builds and ended with 26 actionable tasks: 26 executed; its second run printed 11 actionable tasks: 11 up-to-date and
Configuration cache entry reused.
buildSrc or build-logic
The manual’s recommendation is to use an included build and keep buildSrc for rapid prototyping. Its reasons are classloader
differences between the two (an included build is treated just like an external dependency, which is a simpler mental model), dependency
resolution that behaves subtly differently in buildSrc, the possibility of fewer task invalidations (any change in buildSrc makes the
entire build out of date, while a change in an included build only affects what uses the changed products), included builds that can be
opened and built on their own and published, and the fact that buildSrc applies the java plugin to itself whether or not it needs it. The
manual’s own counterexample is a very large build-logic with many subprojects, where applying different sets of plugins to different
subprojects gives each one a different classpath.
Two of the differences could be observed here, and one expected difference could not:
-
Visibility of the classes. In the
buildSrcbuild, every build script sees the classes ofbuildSrc: a root script withimport io.tidepool.build.TidepoolJavaExtensionand aprintlnof the class name printedio.tidepool.build.TidepoolJavaExtension. Withbuild-logicincluded frompluginManagement, only a script that applies one of its plugins sees them. A script that applies none fails to compile (this run used a copy whose settings file had nobuild-logicsettings plugins; with a settings plugin frombuild-logicapplied, its classes are on the settings classpath and so are visible to every project script, which is another reason to keep settings plugins in a separate build, as below):e: file:///work/tidepool/tidepool-engine/build.gradle.kts:2:9: Unresolved reference 'io'. e: file:///work/tidepool/tidepool-engine/build.gradle.kts:2:27: Unresolved reference 'TidepoolJavaExtension'.The Groovy DSL fails the same way, with
unable to resolve class io.tidepool.build.TidepoolJavaExtension. -
Settings plugins. A plugin in
buildSrcis not available to the settings script: a settings plugin moved intobuildSrcand applied in thepluginsblock of the settings file fails withPlugin [id: 'tidepool.repositories'] was not found(the same message as for a typo, below). A settings plugin from an included build is available, because the included build is declared inpluginManagementof the settings file itself. -
Invalidation. A neutral edit (a comment line added to
tidepool.java-conventions.gradle.kts) was made in thebuildSrcversion and in thebuild-logicversion. In both, the plugin build recompiled (five tasks executed:extractPrecompiledScriptPluginPlugins,generatePrecompiledScriptPluginAccessors,generateScriptPluginAdapters,compileKotlinandjar), every project task stayedUP-TO-DATE, and the configuration cache entry was stored again instead of being reused. The manual’s claim of fewer invalidations with an included build therefore did not show up for this small, single-plugin-project build; it is a statement about larger builds, and it was not reproduced here.
Defaults that a project can override
A convention should be a default, not a straitjacket. The tidepoolJava extension in the plugin above gives the Java release a
convention of 21 with release.convention(21); the compile tasks read the property lazily (options.release = tidepoolJava.release), so
a project that applies the plugin can change the value in its own script, and the change is seen by the tasks without ordering
tricks. The Kotlin DSL generates the accessor tidepoolJava { ... } for the extension because the plugin was applied by id:
tidepoolJava {
release = 25
}
tidepoolJava {
release = 25
}
After ./gradlew assemble the class files of the three projects had these class file major versions (read with javap -v; 65 is
Java 21, 69 is Java 25):
tidepool-model/build/classes/java/main/io/tidepool/model/Station.class
major version: 65
tidepool-engine/build/classes/java/main/io/tidepool/engine/TideEngine.class
major version: 65
tidepool-cli/build/classes/java/main/io/tidepool/cli/Main.class
major version: 69
(Both DSLs gave the same three numbers; compatible with the configuration cache.) Raising the release in the cli project works because a class file for a newer release can use class files compiled for an older one. Lowering it in the engine project does not, because the engine would have to read the model’s class files compiled for 21.
Using the version catalog from a convention plugin
A convention plugin is compiled before the build it serves is configured, so it cannot use the type-safe libs accessor of that build.
It looks the catalog up when it is applied, through the VersionCatalogsExtension of the project it is applied to, as in
java-conventions above: extensions.getByType<VersionCatalogsExtension>().named("libs") returns the catalog libs that the consuming
build declares (here the file gradle/libs.versions.toml), and findLibrary("junit-bom") returns an Optional of the provider for an alias.
The alias is written with a dash (junit-bom), the form it has in the TOML file; the accessor form junit.bom is only for scripts.
The result is that the version 6.1.3 lives only in the catalog, and the Java release (and every other convention) lives only in the plugin:
$ ./gradlew :tidepool-model:dependencies --configuration testRuntimeClasspath
Project ':tidepool-model'
testRuntimeClasspath - Runtime classpath of source set 'test'.
+--- org.junit:junit-bom:6.1.3
| +--- org.junit.jupiter:junit-jupiter:6.1.3 (c)
| +--- org.junit.platform:junit-platform-launcher:6.1.3 (c)
| +--- org.junit.jupiter:junit-jupiter-engine:6.1.3 (c)
...
+--- org.junit.jupiter:junit-jupiter -> 6.1.3
| +--- org.junit.jupiter:junit-jupiter-engine:6.1.3
...
(The listing is cut at the ... lines. Kotlin DSL; the Groovy DSL printed the same first lines of the tree.)
The plugin depends on a convention of the consuming build: a catalog named libs that has an alias junit-bom. If either is missing,
the build fails when the plugin is applied. Both mistakes were tried, and the error does not mention the alias in the second case:
$ ./gradlew help (named("libz"))
* What went wrong:
An exception occurred applying plugin request [id: 'tidepool.application-conventions']
> Failed to apply plugin 'tidepool.java-conventions'.
> Catalog named libz doesn't exist
$ ./gradlew help (findLibrary("junit-bomb"))
* What went wrong:
An exception occurred applying plugin request [id: 'tidepool.application-conventions']
> Failed to apply plugin 'tidepool.java-conventions'.
> No value present
The Groovy DSL printed the same messages, with the nested Failed to apply plugin 'tidepool.application-conventions'. line and the
exception class name in front of the text. To get a message that names the alias, replace the get() with
orElseThrow { GradleException("The catalog libs has no junit-bom") }. Catalogs, bundles, plugin aliases and how an included build can
share the same TOML file are on
Version Catalogs.
Settings convention plugins
The same mechanism works for the settings script. A file whose name ends in .settings.gradle.kts is compiled into a Plugin<Settings>.
The tidepool build uses one to declare the repositories once, so that no project can declare its own:
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
A second settings plugin applies the Develocity plugin and configures its extension. It is here to show the type-safe accessors, which
precompiled settings plugins have since Gradle 9.5, the way project plugins always had them: develocity { ... } is an accessor
for the extension of the plugin applied in the plugins block. The plugin is applied but nothing is published (publishing.onlyIf { false }),
and no terms of use are accepted. Its build file adds the plugin as an implementation dependency, as the manual describes
for external plugins:
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation("com.gradle:develocity-gradle-plugin:4.6.0")
}
plugins {
id("com.gradle.develocity")
}
// Type-safe accessors for the extension of an applied plugin (since Gradle 9.5)
develocity {
buildScan {
publishing.onlyIf { false }
}
}
plugins {
id 'groovy-gradle-plugin'
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation 'com.gradle:develocity-gradle-plugin:4.6.0'
}
plugins {
id 'com.gradle.develocity'
}
develocity {
buildScan {
publishing.onlyIf { false }
}
}
Both versions ran as part of the builds above (the Groovy DSL resolves develocity dynamically and needs no accessor). The release notes
of Gradle 9.5 describe the change: before it, a precompiled Kotlin settings plugin had to configure such an extension through the
string-based API, for example extensions.configure<DevelocityConfiguration> { ... }. That older form, and everything else about
8.14, was not run here. To get the accessors the plugin build must use the kotlin-dsl plugin.
Source: Gradle User Manual: Precompiled script plugin in the Settings scope and Gradle 9.5.0 release notes: Type-safe accessors for precompiled Kotlin Settings plugins
Why a separate build for settings plugins. A settings plugin from an included build must be declared in pluginManagement so that it is
available before the settings script is evaluated. The Gradle manual accepts that, but warns that it reduces build caching and may affect
performance, and recommends a separate, minimal included build that holds only settings plugins; this is the build-logic-settings
above. The visibility experiment of the previous section is the other reason: when a settings plugin comes from build-logic, the
classes of build-logic are on the settings classpath and so leak into every project script.
A plugin has to be applied in the script that matches its type. Applying the settings plugin in a project script, or a project plugin in the settings script, fails with the same message in both directions (Kotlin DSL shown; the Groovy DSL printed the same text for the first):
$ ./gradlew help (id("tidepool.repositories") in tidepool-model/build.gradle.kts)
* What went wrong:
An exception occurred applying plugin request [id: 'tidepool.repositories']
> Failed to apply plugin 'tidepool.repositories'.
> Unexpected plugin type
The plugin must be applied in a settings script (or to the Settings object), but was applied in a build script (or to the Project object)
$ ./gradlew help (id("tidepool.library-conventions") in settings.gradle.kts)
* What went wrong:
An exception occurred applying plugin request [id: 'tidepool.library-conventions']
> Failed to apply plugin 'tidepool.library-conventions'.
> Unexpected plugin type
The plugin must be applied in a build script (or to the Project object), but was applied in a settings script (or to the Settings object)
Classic mistakes
| Mistake | What happens and the fix |
|---|---|
The plugin id has a typo, or |
|
A version in the |
|
The convention reads a catalog or alias that the consuming build lacks |
|
A plugin is applied in the wrong kind of script |
|
A class is used in a build script without applying a plugin from the same build |
|
The Kotlin DSL |
|
Using the old |
Removed in Gradle 9.0. Use extensions and |
A script plugin ( |
It works, but it has no id, no accessors and no tests; |
A last point of design. Keep each convention plugin small and named for one concern (the manual’s java-conventions and
publishing-conventions examples), and compose them from the project scripts: tidepool.library-conventions is Java plus library
semantics, and a publishing plugin would be a separate one, applied by the projects that publish (see
the manual’s publishing example).
When a convention needs a type with its own tests, or has to be used by other builds, promote it to a binary plugin in the same
included build; the plugin ids of the projects do not change. The tidepool checkStations task of
Writing Custom Tasks shows the same idea: its class can
live in build-logic, and a convention plugin registers the task in every project that applies it.
What changed from Gradle 8.14 and from the Gradle 1.x book
Not run here: the following comes from the official documentation and the release notes, because Gradle 8.14.x was not installed.
| Topic | Difference |
|---|---|
Type-safe accessors for precompiled settings plugins |
New in Gradle 9.5 (2026-04-28). In 8.14 a precompiled Kotlin settings plugin configured extensions through string-based APIs such as
|
Running Gradle |
Gradle 9.0 needs JVM 17 or later to run the build, with Kotlin 2.2 and Groovy 4 embedded, so the Groovy and Kotlin of the plugin scripts are newer than in 8.14. See Versions, Compatibility & Release History. |
The |
Removed in 9.0, according to the 9.0 release notes. The extension-and- |
|
Both existed in 8.14 and work the same way for the cases on this page; the recommendation for an included build is part of the 9.x documentation (best practices) and was not checked against the 8.14 manual. |
The Gradle 1.7 edition of Gradle in Action shares logic with script plugins (apply from: 'common.gradle'), allprojects and
subprojects, and a buildSrc directory of classes; the build files in it use apply plugin: 'java' where a current build uses the
plugins block, and the classes of buildSrc are used directly from build scripts. Precompiled script plugins, plugin ids derived from
file names and the recommendation for an included build-logic build are all later than that book.
The same idea in the other two tools
| Concept | Gradle | Maven | Ant |
|---|---|---|---|
Shared project configuration |
A convention plugin in |
A parent POM that projects inherit; see Inheritance, Aggregation & the Effective POM. |
A shared build file pulled in with |
Opt in or inherit |
A project opts in with one line in its |
A project inherits everything from the parent it names. |
The importing file gets everything, and may override targets. |
Shared reusable logic |
Tasks and extensions in the plugin. |
Plugin configuration in |
Macros and presets; see Macros & Presets. |
Where it lives |
A directory ( |
A parent module, or a published parent POM. |
A file in the same repository. |
The cross-tool comparison is on Comparing Ant, Maven & Gradle.
The convention plugins of an Android project, with the Android Gradle Plugin applied in a build-logic included build, are shown on
Project Structure, Gradle and Build Variants.