kotlingradlegradle-kotlin-dslgradle-multi-project-buildkotlin-gradle-plugin

Configure Kotlin extension for Gradle subprojects


I'm setting up a multi-module Gradle project based on Kotlin for the JVM. Since the root project does not contain any code, the Kotlin plugin should only be applied to subprojects.

build.gradle.kts (root project)

plugins {
    kotlin("jvm") version "1.6.20" apply false
}

subprojects {
    apply(plugin = "kotlin")

    group = "com.example"

    repositories {
        mavenCentral()
    }

    dependencies {}

    kotlin {
        jvmToolchain {
            check(this is JavaToolchainSpec)
            languageVersion.set(JavaLanguageVersion.of(11))
        }
    }
}

Trying to set a toolchain causes the build to fail at the kotlin {...} extension:

Unresolved reference. None of the following candidates is applicable because of receiver type mismatch: 
public fun DependencyHandler.kotlin(module: String, version: String? = ...): Any defined in org.gradle.kotlin.dsl
public fun PluginDependenciesSpec.kotlin(module: String): PluginDependencySpec defined in org.gradle.kotlin.dsl

It works fine if I copy the extension definition to each subproject build script, but why isn't it available in the main script?


Solution

  • This is one of my favourite things to fix in Gradle, and really shows off the flexibility that's possible (as well as demonstrating why Gradle can be complicated!)

    First I'll give a bit of background info on the subprojects {} DSL, then I'll show how to fix your script, and finally I'll show the best way to share build logic with buildSrc convention plugins. (Even though it's last, I really recommend using buildSrc!)

    Composition vs Inheritance

    Using allprojects {} and subprojects {} is really common, I see it a lot. It's more similar to how Maven works, where all the configuration is defined in a 'parent' build file. However it's not recommended by Gradle.

    [A], discouraged, way to share build logic between subproject is cross project configuration via the subprojects {} and allprojects {} DSL constructs.

    Gradle Docs: Sharing Build Logic between Subprojects

    (It's probably common because it's easy to understand - it makes Gradle work more like Maven, so each project inherits from one parent. But Gradle is designed for composition. Further reading: Composition over inheritance: Gradle vs Maven)

    Quick fix: 'Unresolved reference'

    The error you're seeing is basically because you haven't applied the Kotlin plugin.

    plugins {
        kotlin("jvm") version "1.6.20" apply false // <- Kotlin DSL won't be loaded
    }
    

    The kotlin { } configuration block is a very helpful extension function that is loaded when the Kotlin plugin is applied. Here's what it looks like:

    /**
     * Configures the [kotlin][org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension] extension.
     */
    fun org.gradle.api.Project.`kotlin`(configure: Action<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension>): Unit =
        (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("kotlin", configure)
    // (note: this is generated code)
    

    So if we don't have the extension function, we can just call configure directly, and thus configure the Kotlin extension.

    subprojects {
      // this is the traditional Gradle way of configuring extensions, 
      // and what the `kotlin { }` helper function will call.
      configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
        jvmToolchain {
          check(this is JavaToolchainSpec)
          languageVersion.set(JavaLanguageVersion.of(11))
        }
      }
      
      // without the Kotlin Gradle plugin, this helper function isn't available
      // kotlin {
      //   jvmToolchain {
      //    check(this is JavaToolchainSpec)
      //     languageVersion.set(JavaLanguageVersion.of(11))
      //   }
      // }
    }
    

    However, even though this works, using subprojects {} has problems. There's a better way...

    buildSrc and Convention Plugins

    buildSrc is, basically, a standalone Gradle project, the output of which we can use in the main project's build scripts. So we can write our own custom Gradle plugins, defining conventions, which we can selectively apply to any subproject in the 'main' build.

    (This is the key difference between Gradle and Maven. In Gradle, a subproject can be configured by any number of plugins. In Maven, there's only one parent. Composition vs Inheritance!)

    The Gradle docs have a full guide on setting up convention plugins, so only I'll briefly summarise the solution here.

    1. Set up ./buildSrc

    Create a directory named buildSrc in your project root.

    Because buildSrc is a standalone project, create a ./buildSrc/build.gradle.kts and ./buildSrc/settings.gradle.kts files, like usual for a project.

    In ./buildSrc/build.gradle.kts,

    1. apply the kotlin-dsl plugin
    2. add dependencies on Gradle plugins that you want to use anywhere in your project
    // ./buildSrc/build.gradle.kts
    plugins {
      `kotlin-dsl` // this will create our Gradle convention plugins
    
      // don't add the Kotlin JVM plugin
      // kotlin("jvm") version embeddedKotlinVersion 
      // Why? It's a long story, but Gradle uses an embedded version of Kotlin,
      // (which is provided by the `kotlin-dsl` plugin)
      // which means importing an external version _might_ cause issues
      // It's annoying but not important. The Kotlin plugin version below, 
      // in dependencies { }, will be used for building our 'main' project.
      // https://github.com/gradle/gradle/issues/16345
    }
    
    val kotlinVersion = "1.9.21"
    
    dependencies {
      implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
    }
    

    Note that I've used the Maven artifact repository coordinates for the Kotlin Gradle plugin, not the plugin ID!

    You can also add other dependencies into ./buildSrc/build.gradle.kts if you like. If you wanted to parse JSON in a build script, then add a dependency on a JSON parser, like kotlinx-serialization.

    2. Create a convention plugin

    Create your Kotlin JVM convention that you can apply to any Kotlin JVM subproject.

    // ./buildSrc/src/main/kotlin/my/project/convention/kotlin-jvm.gradle.kts
    package my.project.convention
    
    plugins {
        kotlin("jvm") // don't include a version - that's provided by ./buildSrc/build.gradle.kts dependencies
    }
    
    dependencies {
      // you can define default dependencies, if desired
      // testImplementation(kotlin("test"))
    }
    
    // you can set defaults for subprojects that apply this plugin
    // (which they can override if necessary)
    kotlin {
      jvmToolchain(11)
    }
    

    Don't forget to add the package declaration! I've forgotten it a few times, and it causes errors that are hard to figure out.

    3. Applying the convention plugin

    Just like how Gradle plugins have IDs, so do our convention plugins. It's the package name + the bit before .gradle.kts. So in our case the ID is my.project.convention.kotlin-jvm

    We can apply this like a regular Gradle plugin...

    // ./subprojects/my-project/build.gradle.kts
    plugins {
      id("my.project.convention.kotlin-jvm")
    }
    

    (Convention plugins can also import other convention plugins, using id("..."))

    Also, since we're using Kotlin, there's an even nicer way. You know how there are included Gradle plugins, like java and java-library. We can import our convention plugins the same way!

    // ./subprojects/my-project/build.gradle.kts
    plugins {
      // id("my.project.convention.kotlin-jvm")
      my.project.convention.`kotlin-jvm` // this works just like id("...") does
    }
    

    Note the backticks around the plugin ID - they're needed because of the hyphen.

    (caveat: this non-id("...") way doesn't work inside buildSrc, only in the main project)

    Result

    Now the root ./build.gradle.kts can be kept really clean and tidy - it only needs to define the group and version of the project.

    Because we're using convention plugins and not blanket subprojects, each subproject can be specialised and only import convention plugins that it needs, without repetition.




    Site note: sharing repositories between buildSrc and the main project

    Usually you want to share repositories between buildSrc and the main project. Because Gradle plugins are not specifically for projects, we can write a plugin for anything, including settings.gradle.kts!

    What I do is create a file with all the repositories I want to use...

    // ./buildSrc/repositories.settings.gradle.kts
    @Suppress("UnstableApiUsage") // centralised repository definitions are incubating
    dependencyResolutionManagement {
    
      repositories {
        mavenCentral()
        jitpack()
        gradlePluginPortal()
      }
    
      pluginManagement {
        repositories {
          jitpack()
          gradlePluginPortal()
          mavenCentral()
        }
      }
    }
    
    
    fun RepositoryHandler.jitpack() {
      maven("https://jitpack.io")
    }
    

    (the name, repositories.settings.gradle.kts, isn't important - but naming it *.settings.gradle.kts should mean IntelliJ provides suggestions, however this is bugged at the moment.)

    I can then import this as a plugin in the other settings.gradle.kts files, just like how you were applying the Kotlin JVM plugin to subprojects.

    // ./buildSrc/settings.gradle.kts
    apply(from = "./repositories.settings.gradle.kts")
    
    // ./settings.gradle.kts
    apply(from = "./buildSrc/repositories.settings.gradle.kts")