Subprojects in a multi-project build typically share some common dependencies.

structuring builds 3

Instead of copying and pasting the same Java version and libraries in each subproject build script, Gradle provides a special directory for storing shared build logic that can be automatically applied to subprojects.

Share logic in buildSrc

buildSrc is a Gradle-recognized and protected directory which comes with some benefits:

  1. Reusable Build Logic:

    buildSrc allows you to organize and centralize your custom build logic, tasks, and plugins in a structured manner. The code written in buildSrc can be reused across your project, making it easier to maintain and share common build functionality.

  2. Isolation from the Main Build:

    Code placed in buildSrc is isolated from the other build scripts of your project. This helps keep the main build scripts cleaner and more focused on project-specific configurations.

  3. Automatic Compilation and Classpath:

    The contents of the buildSrc directory are automatically compiled and included in the classpath of your main build. This means that classes and plugins defined in buildSrc can be directly used in your project’s build scripts without any additional configuration.

  4. Ease of Testing:

    Since buildSrc is a separate build, it allows for easy testing of your custom build logic. You can write tests for your build code, ensuring that it behaves as expected.

  5. Gradle Plugin Development:

    If you are developing custom Gradle plugins for your project, buildSrc is a convenient place to house the plugin code. This makes the plugins easily accessible within your project.

The buildSrc directory is treated as an included build.

For multi-project builds, there can be only one buildSrc directory, which must be in the root project directory.

The downside of using buildSrc is that any change to it will invalidate every task in your project and require a rerun.

buildSrc uses the same source code conventions applicable to Java, Groovy, and Kotlin projects. It also provides direct access to the Gradle API.

A typical project including buildSrc has the following layout:

.
├── buildSrc
│   ├── src
│   │   └──main
│   │      └──kotlin
│   │         └──MyCustomTask.kt    (1)
│   ├── shared.gradle.kts   (2)
│   └── build.gradle.kts
├── api
│   ├── src
│   │   └──...
│   └── build.gradle.kts    (3)
├── services
│   └── person-service
│       ├── src
│       │   └──...
│       └── build.gradle.kts    (3)
├── shared
│   ├── src
│   │   └──...
│   └── build.gradle.kts
└── settings.gradle.kts
1 Create the MyCustomTask task.
2 A shared build script.
3 Uses the MyCustomTask task and shared build script.
.
├── buildSrc
│   ├── src
│   │   └──main
│   │      └──groovy
│   │         └──MyCustomTask.groovy    (1)
│   ├── shared.gradle   (2)
│   └── build.gradle
├── api
│   ├── src
│   │   └──...
│   └── build.gradle    (3)
├── services
│   └── person-service
│       ├── src
│       │   └──...
│       └── build.gradle    (3)
├── shared
│   ├── src
│   │   └──...
│   └── build.gradle
└── settings.gradle
1 Create the MyCustomTask task.
2 A shared build script.
3 Uses the MyCustomTask task and shared build script.

In the buildSrc, the build script shared.gradle(.kts) is created. It contains dependencies and other build information that is common to multiple subprojects:

shared.gradle.kts
repositories {
    mavenCentral()
}

dependencies {
    implementation("org.slf4j:slf4j-api:1.7.32")
}
shared.gradle
repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.slf4j:slf4j-api:1.7.32'
}

In the buildSrc, the MyCustomTask is also created. It is a helper task that is used as part of the build logic for multiple subprojects:

MyCustomTask.kt
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction

open class MyCustomTask : DefaultTask() {
    @TaskAction
    fun calculateSum() {
        // Custom logic to calculate the sum of two numbers
        val num1 = 5
        val num2 = 7
        val sum = num1 + num2

        // Print the result
        println("Sum: $sum")
    }
}
MyCustomTask.groovy
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction

class MyCustomTask extends DefaultTask {
    @TaskAction
    void calculateSum() {
        // Custom logic to calculate the sum of two numbers
        int num1 = 5
        int num2 = 7
        int sum = num1 + num2

        // Print the result
        println "Sum: $sum"
    }
}

The MyCustomTask task is used in the build script of the api and shared projects. The task is automatically available because it’s part of buildSrc.

The shared.gradle(.kts) file is also applied:

build.gradle.kts
// Apply any other configurations specific to your project

// Use the build script defined in buildSrc
apply(from = rootProject.file("buildSrc/shared.gradle.kts"))

// Use the custom task defined in buildSrc
tasks.register<MyCustomTask>("myCustomTask")
build.gradle
// Apply any other configurations specific to your project

// Use the build script defined in buildSrc
apply from: rootProject.file('buildSrc/shared.gradle')

// Use the custom task defined in buildSrc
tasks.register('myCustomTask', MyCustomTask)

Share logic using convention plugins

Gradle’s recommended way of organizing build logic is to use its plugin system.

We can write a plugin that encapsulates the build logic common to several subprojects in a project. This kind of plugin is called a convention plugin.

While writing plugins is outside the scope of this section, the recommended way to build a Gradle project is to put common build logic in a convention plugin located in the buildSrc.

Let’s take a look at an example project:

.
├── buildSrc
│   ├── src
│   │   └──main
│   │      └──kotlin
│   │         └──myproject.java-conventions.gradle.kts  (1)
│   └── build.gradle.kts
├── api
│   ├── src
│   │   └──...
│   └── build.gradle.kts    (2)
├── services
│   └── person-service
│       ├── src
│       │   └──...
│       └── build.gradle.kts    (2)
├── shared
│   ├── src
│   │   └──...
│   └── build.gradle.kts    (2)
└── settings.gradle.kts
1 Create the myproject.java-conventions convention plugin.
2 Applies the myproject.java-conventions convention plugin.
.
├── buildSrc
│   ├── src
│   │   └──main
│   │      └──groovy
│   │         └──myproject.java-conventions.gradle  (1)
│   └── build.gradle
├── api
│   ├── src
│   │   └──...
│   └── build.gradle    (2)
├── services
│   └── person-service
│       ├── src
│       │   └──...
│       └── build.gradle    (2)
├── shared
│   ├── src
│   │   └──...
│   └── build.gradle    (2)
└── settings.gradle
1 Create the myproject.java-conventions convention plugin.
2 Applies the myproject.java-conventions convention plugin.

This build contains three subprojects:

settings.gradle.kts
rootProject.name = "dependencies-java"
include("api", "shared", "services:person-service")
settings.gradle
rootProject.name = 'dependencies-java'
include 'api', 'shared', 'services:person-service'

The source code for the convention plugin created in the buildSrc directory is as follows:

buildSrc/src/main/kotlin/myproject.java-conventions.gradle.kts
plugins {
    id("java")
}

group = "com.example"
version = "1.0"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("junit:junit:4.13")
}
buildSrc/src/main/groovy/myproject.java-conventions.gradle
plugins {
    id 'java'
}

group = 'com.example'
version = '1.0'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation "junit:junit:4.13"
}

For the convention plugin to compile, basic configuration needs to be applied in the build file of the buildSrc directory:

buildSrc/build.gradle.kts
plugins {
    `kotlin-dsl`
}

repositories {
    mavenCentral()
}
buildSrc/build.gradle
plugins {
    id 'groovy-gradle-plugin'
}

The convention plugin is applied to the api, shared, and person-service subprojects:

api/build.gradle.kts
plugins {
    id("myproject.java-conventions")
}

dependencies {
    implementation(project(":shared"))
}
shared/build.gradle.kts
plugins {
    id("myproject.java-conventions")
}
services/person-service/build.gradle.kts
plugins {
    id("myproject.java-conventions")
}

dependencies {
    implementation(project(":shared"))
    implementation(project(":api"))
}
api/build.gradle
plugins {
    id 'myproject.java-conventions'
}

dependencies {
    implementation project(':shared')
}
shared/build.gradle
plugins {
    id 'myproject.java-conventions'
}
services/person-service/build.gradle
plugins {
    id 'myproject.java-conventions'
}

dependencies {
    implementation project(':shared')
    implementation project(':api')
}

Do not use cross-project configuration

An improper way to share build logic between subprojects is cross-project configuration via the subprojects {} and allprojects {} DSL constructs.

Avoid using subprojects {} and allprojects {}.

With cross-configuration, build logic can be injected into a subproject which is not obvious when looking at its build script.

In the long run, cross-configuration usually grows in complexity and becomes a burden. Cross-configuration can also introduce configuration-time coupling between projects, which can prevent optimizations like configuration-on-demand from working properly.

Convention plugins versus cross-configuration

The two most common uses of cross-configuration can be better modeled using convention plugins:

  1. Applying plugins or other configurations to subprojects of a certain type.
    Often, the cross-configuration logic is if subproject is of type X, then configure Y. This is equivalent to applying X-conventions plugin directly to a subproject.

  2. Extracting information from subprojects of a certain type.
    This use case can be modeled using outgoing configuration variants.