A plugin that will bring type-safety to your convention plugins!
Rationale
·
Quickstart
·
Features
·
Less common use cases
·
Troubleshooting
plugins {
- id("org.jetbrains.kotlin.jvm")
+ alias(libs.plugins.kotlin.jvm)
- id("conventions.kotlin")
+ alias(conventions.plugins.kotlin)
}
dependencies {
- implementation(versionCatalogs.find("libs").get().findLibrary("kotlin-stdlib").get())
+ implementation(libs.kotlin.stdlib)
}According to the Gradle docs, it is recommended to place convention plugins inside the build-logic included build. In an ideal world, we would just copy some part of build.gradle.kts and put it inside a convention plugin. However, there are some serious limitations:
- the convention plugin can't use version catalog typesafe accessors
- the buildscript of
build-logicdoesn't have access to the version catalog of the main build - if a convention plugin wants to apply an external plugin, the plugin dependency must be manually added
- there is no built-in way to convert plugin ID to plugin dependency
The typesafe-conventions plugin aims to solve these problems.
If you prefer watching over reading, check out this cool video from Duncan McGregor 😉
- Gradle 8.8+
- JDK 17+
- Either local or imported version catalog is used
- There is an included build for build logic (we will refer to it as
build-logic) - At least one project within
build-logichas precompiled script plugins enabled (you can do this by applying thekotlin-dslplugin)
Apply the typesafe-conventions plugin in build-logic/settings.gradle.kts:
Important
It's a settings plugin (not a project plugin) so apply it in settings.gradle.kts!
plugins {
id("dev.panuszewski.typesafe-conventions") version "0.11.1"
}Your project structure should be similar to the following:
.
├── gradle/
│ └── libs.versions.toml
├── settings.gradle.kts
├── build.gradle.kts
├── ...
└── build-logic/
├── settings.gradle.kts # apply 'typesafe-conventions' here
├── build.gradle.kts
└── src/
└── main/
└── kotlin/
└── some-convention.gradle.ktsThe following snippet presents all possible configuration options with their default values:
typesafeConventions {
// enable or disable support for version catalog typesafe accessors in plugins block of a convention plugin
accessorsInPluginsBlock = true
// enable or disable auto dependency for every alias(...) plugin declaration in a convention plugin
// set it to 'false' if you prefer to add plugin marker dependencies manually (you can use the pluginMarker helper method for that)
autoPluginDependencies = true
// whether to allow plugin usage for a top-level build
// set it to 'true' only if you know what you're doing!
allowTopLevelBuild = false
conventionCatalog {
// enable or disable support for convention catalog
enabled = true
// name of the version catalog that will contain convention plugins
catalogName = "conventions"
// whether to skip package names in convention catalog entries
ignorePackageNames = false
}
}In Gradle < 8.8, you should use the following syntax instead:
import dev.panuszewski.gradle.TypesafeConventionsExtension
configure<TypesafeConventionsExtension> {
accessorsInPluginsBlock = true
// ...
}build-logic/src/main/kotlin/some-convention.gradle.kts:
dependencies {
- implementation(versionCatalogs.find("libs").get().findLibrary("kotlin-stdlib").get())
+ implementation(libs.kotlin.stdlib)
}Details
In plain Gradle, applying dependency from version catalog in a convention plugin would look like this:
dependencies {
implementation(versionCatalogs.find("libs").get().findLibrary("kotlin-stdlib").get())
}With typesafe-conventions, you can benefit from type-safe syntax:
dependencies {
implementation(libs.kotlin.stdlib)
}If you keep your convention plugins in a named package (for example in build-logic/src/main/kotlin/com/myapp/gradle), you need to explicitly import the libs extension:
package com.myapp.gradle
+import libs
dependencies {
implementation(libs.kotlin.stdlib)
}build-logic/src/main/kotlin/some-convention.gradle.kts:
plugins {
- id("org.jetbrains.kotlin.jvm")
+ alias(libs.plugins.kotlin.jvm)
}build-logic/build.gradle.kts:
-dependencies {
- implementation(pluginMarker(libs.plugins.kotlin.jvm))
-}
-
-fun pluginMarker(provider: Provider<PluginDependency>): String {
- val plugin = provider.get()
- return "${plugin.pluginId}:${plugin.pluginId}.gradle.plugin:${plugin.version}"
-}Tip
The plugin dependency is automatically added to the project that hosts your convention plugin.
Details
Let's assume you have the following plugin declared in your `libs.versions.toml`: ```toml kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.1.10" } ```In plain Gradle, to apply external plugin from your convention plugin, you would need to add it as dependency to your build-logic/build.gradle.kts:
dependencies {
implementation(pluginMarker(libs.plugins.kotlin.jvm))
}
/**
* This is a custom helper method to convert plugin ID into plugin marker artifact.
* Alternatively, you could add a dependency to "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10",
* but you need to know the exact coordinates of the plugin artifact (which can be arbitrary).
*/
fun pluginMarker(provider: Provider<PluginDependency>): String {
val plugin = provider.get()
return "${plugin.pluginId}:${plugin.pluginId}.gradle.plugin:${plugin.version}"
}And then apply it in your convention plugin via id(...) syntax:
plugins {
id("org.jetbrains.kotlin.jvm")
}With typesafe-conventions, you can just apply the plugin via alias(...) syntax in your convention plugin:
plugins {
alias(libs.plugins.kotlin.jvm)
}If you prefer to add the plugin dependencies manually, you can opt out from the auto dependencies feature in your build-logic/settings.gradle.kts:
typesafeConventions {
autoPluginDependencies = false
}And use the pluginMarker helper method in build-logic/build.gradle.kts:
import dev.panuszewski.gradle.pluginMarker
dependencies {
implementation(pluginMarker(libs.plugins.kotlin.jvm))
}The auto dependencies will have the weakest version constraint (prefer) so you can easily override the version by simply declaring the dependency.
[!WARNING] This method works only for plugin marker artifacts, not the actual artifacts that contain plugin code! For example, it works for
org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin, but not fororg.jetbrains.kotlin:kotlin-gradle-plugin
You may want to do it when you determine the version dynamically:
import dev.panuszewski.gradle.pluginMarker
dependencies {
val kotlinVersion = resolveKotlinVersionFromSpringBom()
// hardcode the coordinates...
implementation("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:$kotlinVersion")
// ...or use version catalog accessor and set stronger constraint
// (the accessor comes with 'require' constraint by default, so you need to use 'strictly')
implementation(pluginMarker(libs.plugins.kotlin.jvm)) { version { strictly(kotlinVersion) } }
}build-logic/settings.gradle.kts:
-dependencyResolutionManagement {
- versionCatalogs {
- create("libs") {
- from(files("../gradle/libs.versions.toml"))
- }
- }
-}Details
In plain Gradle, using version catalog in build-logic/build.gradle.kts would require manually registering it in the build-logic/settings.gradle.kts.
After applying typesafe-conventions, you don't need the above configuration — it works out-of-the-box.
Warning
This feature is not available for buildSrc! If you want to use it, please migrate to build-logic.
See Gradle Best Practices for more details.
build.gradle.kts:
plugins {
- id("some-convention")
+ alias(conventions.plugins.some.convention)
}build-logic/src/main/kotlin/some-convention.gradle.kts:
plugins {
- id("another-convention")
+ alias(conventions.plugins.another.convention)
}build-logic/src/main/kotlin/another-convention.gradle.kts:
// the alias used above is generated due to existence of this fileDetails
In plain Gradle, you must provide a convention plugin name via raw String:
plugins {
id("some-convention")
}The typesafe-conventions plugin automatically creates the convention catalog for you. It is a special version catalog that contains references to all your convention plugins.
You can use it to apply your convention plugin in a type-safe way:
plugins {
alias(conventions.plugins.some.convention)
}If you don't like the default name conventions, you can change it:
typesafeConventions {
conventionCatalog {
catalogName = "buildlogic"
}
}plugins {
alias(buildlogic.plugins.some.convention)
}If your convention plugin is placed in a named package, the package name will be included in the resulting alias in the convention catalog:
build-logic/src/main/kotlin/com/example/some-convention.gradle.kts:
package com.example
// some config...build.gradle.kts:
plugins {
alias(conventions.plugins.com.example.some.convention)
}You can ignore the package names to get shorter aliases:
typesafeConventions {
conventionCatalog {
ignorePackageNames = true
}
}WARNING: When this property is set to true, every convention plugin name must be unique!
It's pretty easy to create a convention plugin with the same name as a built-in plugin.
For example, it may be tempting to create publishing.gradle.kts convention plugin for shared publishing configuration. However, the resulting ID of such plugin would be just publishing, which is the same as the built-in Gradle publishing plugin.
To avoid such conflicts, you can put your publishing.gradle.kts into a named package (e.g conventions):
// build-logic/src/main/kotlin/conventions/publishing.gradle.kts
package conventionsTo keep the plugin alias nice and short, you can also enable ignoring package names:
typesafeConventions {
conventionCatalog {
ignorePackageNames = true
}
}This way, you can apply your plugins like this:
plugins {
publishing // the built-in plugin
alias(conventions.plugins.publishing) // your convention plugin
}Convention catalog will only discover *.gradle.kts placed somewhere inside the src dir (like src/main/kotlin). If you don't change Gradle defaults, everything will work for you.
However, if you do change the default location, bear in mind that it must be placed within src directory. This directory may be nested at any level.
Example convention plugins that will be discovered:
src/main/kotlin/foo.gradle.ktssrc/custom/foo.gradle.ktssrc/foo.gradle.ktssubproject/src/foo.gradle.ktsa/b/c/src/foo.gradle.kts
Example convention plugins that won't be discovered:
foo.gradle.ktssubproject/foo.gradle.ktsbuild/foo.gradle.kts.gradle/foo.gradle.kts
It's perfectly OK to use other version catalogs than libs as typesafe-conventions supports them out-of-the-box!
Create a file like gradle/custom.versions.toml with the contents similar to gradle/libs.versions.toml.
Remember that you must manually register it in settings.gradle.kts! (that's how Gradle works, nothing to do with typesafe-conventions):
dependencyResolutionManagement {
versionCatalogs {
create("custom") {
from(files("gradle/custom.versions.toml"))
}
}
}Warning
This feature is not available for builds included within the pluginManagement { ... } block!
(see known limitations)
Import the catalog in your settings.gradle.kts:
dependencyResolutionManagement {
versionCatalogs {
create("mn") {
from("io.micronaut.platform:micronaut-platform:4.8.2")
}
}
}In the example above, we import the version catalog provided by Micronaut.
Gradle allows you to include builds in your settings.gradle.kts like this:
pluginManagement {
includeBuild("build-logic")
}This instructs the build-logic to evaluate before settings of the main build and thus allows you to write convention plugins to be applied in settings.gradle.kts of the main build.
Unfortunately, the above config makes it impossible for the included build to inherit imported version catalogs of the main build (as those become available after the main build's settings are evaluated). Additionally, it's impossible to rebuild the original build hierarchy for early evaluated builds before they were flattened by Gradle.
In a rare case when you need to provide a settings convention plugin, please extract it to a separate included build and refrain from using typesafe-conventions in that build.
Most of the time, though, it is perfectly OK to migrate your build-logic to a regular included build:
-pluginManagement {
includeBuild("build-logic")
-}Your build-logic can have multiple subprojects with convention plugins. For example, you can have something like this:
.
├── gradle/
│ └── libs.versions.toml
├── build.gradle.kts
├── settings.gradle.kts # includeBuild("build-logic")
├── ...
└── build-logic/
├── settings.gradle.kts # apply 'typesafe-conventions' here
├── ...
├── first-convention-plugin/
│ └── build.gradle.kts # apply 'kotlin-dsl' here
└── second-convention-plugin/
└── build.gradle.kts # apply 'kotlin-dsl' hereIn most cases, you should apply typesafe-conventions to either included build or build-logic, because that's
where convention plugins are typically stored and the included build will "inherit" version catalogs from
the main build.
By default, applying typesafe-conventions to a top-level build is not allowed, but you can change this default in your settings.gradle.kts:
typesafeConventions {
allowTopLevelBuild = true
}Warning
Allow top-level build only if you know what you're doing!
If you apply typesafe-conventions to a top-level build, be aware that your convention plugins will be
compiled against typesafe accessors generated for version catalogs (TOML files) your local build has access to.
If you publish your convention plugins to remote repo and use them in another build, make sure the version
catalogs in that another build are exactly the same as in the original one. Otherwise, you will end up if
referencing catalog entries that are not existing!
A valid use case is when you publish your convention plugins together with the version catalog (with the same version) and the catalog has entry to self-reference this version. This way, you can assure the version catalog and convention plugins are always in sync.
If you use any linters or static analysis tools, like ktlint-gradle, kotlinter or detekt, Gradle may complain about implicit task dependencies:
Task ':build-logic:runKtlintCheckOverMainSourceSet' uses this output of task ':build-logic:generateEntrypointForLibs' without declaring an explicit or implicit dependency. This can lead to incorrect results being produced, depending on what order the tasks are executed.
To fix this issue, you should exclude the code generated by the typesafe-conventions from static analysis. All code generated by this plugin is placed under build/generated-sources/typesafe-conventions directory.
Tip
It may be a good idea to exclude all generated code from static analysis, not only the classes generated by typesafe-conventions!
For ktlint-gradle, it may look like this:
ktlint {
filter {
exclude { it.file.path.contains("build/generated-sources/typesafe-conventions") }
}
}