Bootiful Builds — Best Practices for Building Spring Boot Apps with Gradle

Eric Haag | May 27, 2024 min read

Introduction

You have an idea for a new application, so naturally you head to the Spring Initializr to generate a new Spring Boot project. You decide to select Gradle as your build tool of choice and the default build tool for the Spring Initializr since 2022. After all, Gradle decreased the Spring Boot team’s CI build times by 3-4x and local build times by 20-30x. You may notice however that the Gradle build generated by the Spring Initializr is quite bare and doesn’t leverage many of the exciting new features introduced in recent Gradle versions.

In this blog post, we’ll explore the best ways to improve the speed, reliability, and organization of your Gradle build. These features can be applied to both newly generated and existing Spring Boot applications. However, they aren’t exclusive to Spring Boot and could very well be applied to any application using Gradle.

This blog post is always kept up-to-date, tested, and verified with the latest versions of Gradle and Spring Boot. Its contents may even change over time as new features are released. It serves as a great reference for staying up-to-date on the latest features and best practices.

1. Use the Gradle Kotlin DSL

The Gradle Kotlin DSL provides a type-safe way to configure your build and provides better integration with IDEs like IntelliJ IDEA than the traditional Groovy DSL. If you’ve never used Kotlin before or are only familiar with the Groovy DSL, you have nothing to worry about — the Kotlin DSL is easy to pick up and uses the same familiar Gradle APIs as the Groovy DSL.

If you’re already using the Groovy DSL, you can migrate your build scripts to the Kotlin DSL, but it may or may not be trivial depending on how complex your build is. Fortunately, the Gradle documentation has a guide outlining the Kotlin DSL migration process, and it can be done incrementally by migrating one build script at a time.

If you haven’t generated your project on the Spring Initializr yet, you should choose Gradle - Kotlin as the project type and start using the Kotlin DSL from the very beginning.

Here’s a minimal example of a build.gradle.kts file for a Spring Boot project using the Kotlin DSL:

plugins {
    java
    id("org.springframework.boot") version "3.3.1"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter:3.3.1")
}

Kotlin is interoperable with Java and fully compatible with Spring Boot, so it’s a great language to use for writing your Spring Boot applications too!

You can learn more about the Kotlin DSL in the Gradle documentation.

2. Upgrade Gradle to the latest version

Using the latest version of Gradle not only brings the latest features, but also ensures you’re benefitting from the latest performance improvements and security patches.

If you’re generating your project using the Spring Initializr, it’s worth noting that projects do not always come with the latest version of Gradle, so it’s a good idea for this to be one of the first things you do.

As an added layer of security, I also recommend configuring distribution checksum verification which will fail the build if the configured checksum doesn’t match the Gradle distribution you’re downloading. You’ll need to grab the correct checksum for the version you’re upgrading to (shown below).

You can use the wrapper task to upgrade the Wrapper to the latest version. You should always run the Wrapper upgrade command twice. The first run will update the gradle-wrapper.properties file while the second may download a new Wrapper jar and scripts.

./gradlew wrapper --gradle-version=8.9 --gradle-distribution-sha256-sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab

You can learn more about the Gradle Wrapper in the Gradle documentation.

3. Publish Gradle build scans

A build scan is a web-based report that captures everything about your build, like: the outcome of every task, test results, build performance metrics, resolved dependencies, the console log, and more. The deep insights on build failures and deprecations from a build scan will also help troubleshoot problems you may encounter.

You can publish a one-off build scan by invoking Gradle with the --scan argument, but I recommend configuring your build to publish a build scan on every build invocation. You can configure build scan publishing by adding the Develocity Gradle plugin to your settings.gradle.kts file. I also recommend adding the Common Custom User Data Gradle plugin which will add metadata like the current Git branch and commit hash to the published build scans. When a build scan is published, you’ll see a unique link printed to the console at the end of the build that takes you to the build scan.

Add the following to your settings.gradle.kts file to configure build scans for your build:

plugins {
    id("com.gradle.develocity") version "3.17.5"
    id("com.gradle.common-custom-user-data-gradle-plugin") version "2.0.2"
}

develocity {
    buildScan {
        uploadInBackground = !System.getenv().containsKey("CI")
        termsOfUseAgree = "yes"
        termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use"
    }
}

Gradle also offers a paid version of build scans called Develocity that offers additional performance acceleration features. In fact, Gradle partners with the Spring team and other OSS projects to provide them with free instances of Develocity. The Spring team’s Develocity instance is publicly visible, and you can check it out at ge.spring.io.

You can learn more about build scans on Gradle’s website.

4. Enable build caching

When Gradle encounters a task that it has already executed it won’t run it again if the output of that task already exists in the build directory. This will result in the outcome of that task being UP-TO-DATE. This is called incremental build. Incremental build is enabled by default, and can significantly improve your build’s execution speed. The downside of incremental build is that it can only skip tasks whose inputs haven’t changed since the most recent build invocation.

Build caching however provides a persistent location for task results outside the build directory. This means build caching can restore the output of any cacheable task that has been previously computed and isn’t limited to the most recent build invocation. While mostly negligible, there is a time cost in storing and loading cache entries, meaning incremental build is still preferred since it has no additional overhead.

When you enable build caching, Gradle will store the outputs of all cacheable tasks in a directory on disk, typically located at ~/.gradle/caches/build-cache-1. This is called the local build cache. You can also configure a remote build cache that allows your team to share build cache entries across machines to speed up both local developer and CI builds.

You can enable build caching by invoking Gradle with the --build-cache command line option, or enable it permanently by adding the following property to your gradle.properties file:

org.gradle.caching=true

You can learn more about build caching in the Gradle documentation.

5. Enable parallel execution

When you have a build with multiple projects, the default behavior is to build them serially, that is one at a time, even if those projects are not dependent on one another.

When parallel execution is enabled, Gradle will build any non-dependant projects in parallel. This is one of the easiest and lowest effort ways to speed up your build.

You can enable parallel execution by invoking Gradle with the --parallel command line parameter, or enable it permanently by adding the following property to your gradle.properties file:

org.gradle.parallel=true

You can learn more about parallel execution in the Gradle documentation.

6. Enable configuration caching

A Gradle build is broken up into three distinct phases: initialization, configuration, and execution.

The initialization phase bootstraps the build environment and typically only takes a few seconds at most.

The configuration phase evaluates the build.gradle.kts file of every project and constructs the task graph. This phase can take quite some time depending on the complexity of the build.

The execution phase is when tasks are actually run. As we’ve seen, you can leverage incremental build and the build cache to speed up this phase.

The configuration cache is similar to the build cache, but will cache the result of the configuration phase such that you can skip the configuration phase of subsequent builds for the same set of requested tasks. Any change to the build will invalidate the configuration cache, requiring the configuration phase to run again.

As an added bonus, the configuration cache brings a finer-grained parallelism model than parallel execution. Unlike parallel execution, enabling the configuration cache allows non-dependant tasks from the same project to run in parallel.

You can enable configuration caching by invoking Gradle with the --configuration-cache command line parameter, or enable it permanently by adding the following property to your gradle.properties file:

org.gradle.configuration-cache=true

Until Gradle 8.1, the configuration cache was an experimental feature, so if you’re on an older version you’ll need to use a different property:

org.gradle.unsafe.configuration-cache=true

You can learn more about configuration caching in the Gradle documentation.

7. Replace io.spring.dependency-management with a Gradle platform

The primary use case for the Spring Dependency Management plugin is to provide support for importing Maven BOMs to align dependency versions. When you import a Maven BOM, you no longer have to explicitly declare versions for dependencies that are defined in the BOM as their versions are aligned automatically.

Gradle has had the ability to import a Maven BOM since Gradle 5.0 released in 2018.

When you use the Spring Dependency Management plugin the Spring Boot plugin will automatically import the spring-boot-dependencies BOM. This is why you do not have to specify the version for many Spring dependencies.

To achieve the same result using Gradle’s native BOM support you should replace the plugin with a platform that imports the spring-boot-dependencies BOM. The Spring Boot plugin conveniently exposes a BOM_COORDINATES constant that can be used to declare the platform. This will import the same version of the BOM as that of the Spring Boot Gradle plugin.

Here are the changes you need to make in order to replace the Spring Dependency Management plugin with Gradle’s native BOM support:

import org.springframework.boot.gradle.plugin.SpringBootPlugin

plugins {
    java
    id("org.springframework.boot") version "3.3.1"
    
    // Delete this - it's no longer necessary!
    //id("io.spring.dependency-management") version "1.1.6"
}

dependencies {
    implementation(platform(SpringBootPlugin.BOM_COORDINATES))
    
    // Look, no versions required!
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("com.fasterxml.jackson.core:jackson-databind")

  // If you're using a Spring Cloud dependency you can import its BOM too.
    implementation(platform("org.springframework.cloud:spring-cloud-dependencies:2023.0.3"))
}

You can learn more about platforms and BOM support in the Gradle documentation.

8. Auto-provision Java using toolchains

Keeping Java versions in-sync between everyone on your team and your CI runners can be challenging, if not nearly impossible. On top of that, some projects may have different Java version requirements than others. It’s easy to inadvertently waste time when things aren’t working right as a result of using the wrong Java version on a project — we’ve all been there.

Fortunately, you can leave it to Gradle to align the Java version in your projects using Java toolchains. Java toolchains allow you to specify the version, vendor, and implementation of Java to use for a project. If a compatible toolchain can’t be found locally on the machine, Gradle can even auto-provision (i.e. automatically download) one that is compatible if a toolchain resolver plugin is applied to the build. One such toolchain resolver plugin is the Foojay Toolchains Resolver plugin which is maintained by the Gradle team themselves.

You can define the Java toolchain to use by adding the following to your build.gradle.kts file:

java {
  toolchain {
    languageVersion = JavaLanguageVersion.of(21)
    vendor = JvmVendorSpec.ADOPTIUM
  }
}

In order to auto-provision JDKs using the Foojay Toolchains Resolver plugin, you can add the following to your settings.gradle.kts file:

plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}

You can learn more about Java toolchains in the Gradle documentation.

9. Organize tests using test suites

At some point you’ve likely stared at a project’s tests, entirely unsure about what type of tests they’re supposed to be. It’s common for developers to shove unit tests, integration tests, and end-to-end tests together to create one big monolithic test suite.

Gradle’s test suites allow you to separate a project’s tests into logical groups, creating a clear separation of concern. They make it possible to run only a subset of tests or to ensure that one type of test runs before the other.

In a Spring Boot project, it’s typical to have integration tests that spin up the full application context, a database, a web server, etc. Since these are so expensive to run, separating them from unit tests can be useful because it allows you to run only one at a time. You can also enforce your unit tests to run before your Spring Boot tests when both are present in the task graph. This gives you the fast feedback you need when making incremental changes while ensuring the Spring Boot tests will run eventually.

You can add the following configuration to your build.gradle.kts file to create a dedicated springBootTest test suite:

plugins {
  `jvm-test-suite`
}

val test by testing.suites.getting(JvmTestSuite::class) {
  useJUnitJupiter()
}

val springBootTest by testing.suites.creating(JvmTestSuite::class) {
  useJUnitJupiter()
  dependencies {
    implementation(project())
    implementation(platform("org.springframework.boot:spring-boot-dependencies:3.3.1"))
    implementation("org.springframework.boot:spring-boot-starter-test")
  }
  targets.all {
    testTask {
      shouldRunAfter(tasks.test)
    }
  }
}

tasks.check {
  dependsOn(testing.suites)
}

You can learn more about test suites in the Gradle documentation.

10. Centralize dependencies using version catalogs

When a build contains multiple projects that should share the same dependency versions, it can be difficult to keep them all in sync. It’s also easy to forget the name of a dependency, requiring you to leave your IDE to Google it, or to misspell the name of a dependency, causing errors during dependency resolution.

Version catalogs allow you to centrally declare the dependencies and plugins used in your application. They can also be published as a plugin and consumed by other builds to ensure consistent dependency versions across many applications at once.

Version catalogs are written in TOML and located at gradle/libs.versions.toml. The following is an example version catalog for a Spring Boot application:

[versions]
apache-commons-text = "1.12.0"
spring-boot = "3.3.1"

[libraries]
apache-commons-text = { module = "org.apache.commons:commons-text", version.ref = "apache-commons-text" }
spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" }
spring-boot-starter-core = { module = "org.springframework.boot:spring-boot-starter" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }

For each dependency in a version catalog, Gradle automatically generates a type-safe dependency accessor, allowing for auto-completion from within an IDE. The following is how you can reference the dependencies defined in a version catalog:

plugins {
  // Use them to declare plugins.
  alias(libs.plugins.spring.boot)
}

dependencies {
  // Use them to declare platforms.
  implementation(platform(libs.spring.boot.dependencies))

  // Use them to declare project dependencies.
  implementation(libs.spring.boot.starter.core)
}

val springBootTest by testing.suites.creating(JvmTestSuite::class) {
  useJUnitJupiter()
  dependencies {
    implementation(project())

    // Use them to declare test suite.
    implementation(platform(libs.spring.boot.dependencies))
    implementation(libs.spring.boot.starter.test)
  }
}

It’s worth mentioning that GitHub’s Dependabot understands and can parse version catalogs to ensure the dependencies declared there remain up-to-date.

You can learn more about version catalogs in the Gradle documentation.

11. Organize your build logic using convention plugins

As your build grows, it’s natural for duplication to occur. For example, most of your Java projects may configure the same Java toolchain version or use JUnit Jupiter in the default test suite.

You can eliminate this duplication by organizing your build logic into convention plugins. These allow you to group your build logic for reuse throughout the build. This both cuts down on duplication and reduces the cognitive load required to understand your build scripts.

Convention plugins can be local to a build, meaning it isn’t necessary to publish them to a plugin repository. They can be defined in a directory named buildSrc at the root of the build. In effect, this directory is like a secondary build exclusively for housing build logic.

There are a few necessary files that are required before you can write your first convention plugin. First, you need to create a settings file at buildSrc/settings.gradle.kts:

// By default, buildSrc doesn't provide access to the parent build's version
// catalog.
dependencyResolutionManagement {
  versionCatalogs {
    create("libs") {
      from(files("../gradle/libs.versions.toml"))
    }
  }
}

rootProject.name = "buildSrc"

Second, you need to create a build script at buildSrc/build.gradle.kts:

plugins {
  `kotlin-dsl`
}

repositories {
  mavenCentral()
}

dependencies {
  // Allows the Spring Boot plugin to be used in the convention plugins.
  implementation(libs.spring.boot.gradle.plugin)
}

Now you can write your first convention plugin at buildSrc/src/main/kotlin/conventions.java.gradle.kts. The following is an example of a convention plugin defining Java conventions.

plugins {
  id("java")
  id("jvm-test-suite")
}

repositories {
  mavenCentral()
}

java {
  toolchain {
    languageVersion = JavaLanguageVersion.of(21)
    vendor = JvmVendorSpec.ADOPTIUM
  }
}

val test by testing.suites.getting(JvmTestSuite::class) {
  useJUnitJupiter()
}

tasks.check {
  dependsOn(testing.suites)
}

As you can see, it looks exactly like a regular build script.

This convention plugin can now be applied to any build script of the parent build. For example, given a build script named core/build.gradle.kts, you can apply the convention plugin as follows:

plugins {
  id("conventions.java")
}

dependencies {
  // Regular project dependencies.
  implementation(libs.apache.commons.text)
}

The ID of a convention plugin is derived from its filename. In this case, since the convention plugin is named conventions.java.gradle.kts, it’s applied using the plugin ID conventions.java. You can choose any name for your convention plugins, but I recommend being consistent in the naming convention.

Here is another example of a convention plugin defining Spring Boot conventions, located at buildSrc/src/main/kotlin/conventions.spring-boot.gradle.kts:

plugins {
  // Spring Boot projects should also use the Java conventions.
  id("conventions.java")
  id("org.springframework.boot")
}

// Type safe dependency accessors aren't accessible within 'buildSrc/src/main/kotlin'.
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

dependencies {
  // Removes the need to specify the Spring Boot BOM in every Spring Boot project.
  implementation(platform(libs.findLibrary("spring-boot-dependencies").get()))
}

// All Spring Boot projects should have a 'springBootTest' test suite.
val springBootTest by testing.suites.creating(JvmTestSuite::class) {
  useJUnitJupiter()
  dependencies {
    implementation(project())
    implementation(platform(libs.findLibrary("spring-boot-dependencies").get()))
    implementation(libs.findLibrary("spring-boot-starter-test").get())
  }
  targets.all {
    testTask {
      shouldRunAfter(tasks.test)
    }
  }
}

Given a build script named app/build.gradle.kts, you can apply the convention plugin as follows:

plugins {
  id("conventions.spring-boot")
}

dependencies {
  // Regular project dependencies.
  implementation(libs.spring.boot.starter.core)
  implementation(project(":core"))
}

Convention plugins are a great way to cut down on duplication of build logic, but be careful not to overuse them. As a rule of thumb, avoid convention plugins until the moment you copy and paste something from one build script to another. When that happens, it may be time to introduce a convention plugin.

You can learn more about build organization and convention plugins in the Gradle documentation.

Conclusion

It’s easy to only focus on application code and neglect the build. Keeping the build organized and up-to-date with the latest features can go a long way towards boosting productivity and developer happiness.

Your Spring Boot applications should be bootiful. Why shouldn’t their builds be bootiful too?

If you have feedback on this blog post, create an issue on GitHub!