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

Eric Haag | Mar 31, 2024 min read

Introduction

You have an idea for a new microservice, 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 development team’s CI build times by 3-4x and local build times by 20-30x, so it’s a great choice. 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 versions of Gradle.

In this blog post, we’ll explore the best ways to improve the speed, reliability, and organization of your build. These tips can be applied to both existing and newly generated Spring Boot projects.

While this blog post is written in the context Spring Boot, these would be great changes to any project using Gradle.

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 scripts are. 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 yet, you should choose Gradle - Kotlin as the project type on the Spring Initializr and start using the Kotlin DSL from the very beginning.

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

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

repositories {
    mavenCentral()
}

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

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

You can learn more about the Kotlin DSL in the Gradle documentation: https://docs.gradle.org/current/userguide/kotlin_dsl.html

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 vulnerability 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 does not match the checksum for the Gradle distribution you’re downloading. You will need to grab the correct checksum for the version you’re upgrading to.

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

./gradlew wrapper --gradle-version=8.7 --gradle-distribution-sha256-sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d

You can learn more about the Gradle Wrapper in the Gradle documentation: https://docs.gradle.org/current/userguide/gradle_wrapper.html

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 in your build.

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 additional 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 will take you to the build scan.

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

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

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 and provides them with free instances of Develocity. The Spring team’s Develocity instance is visible publicly, and you can check it out for yourself at ge.spring.io.

You can learn more about build scans on Gradle’s website: https://scans.gradle.com/

4. Enable build caching

When Gradle encounters a task that it has already executed it will not 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 it can significantly improve the speed of your build. The downside of incremental build is that it can only skip tasks whose inputs have not 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 is not 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 teams to share build cache entries across machines to speed up both local developer builds 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: https://docs.gradle.org/current/userguide/build_cache.html

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: https://docs.gradle.org/current/userguide/performance.html#parallel_execution

6. Enable configuration cache

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, we 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 we 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 the configuration cache 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 will need to use a different property:

org.gradle.unsafe.configuration-cache=true

You can learn more about the configuration cache in the Gradle documentation: https://docs.gradle.org/current/userguide/configuration_cache.html

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 automatically aligned. There are other features of the plugin too, such as the ability to reference properties defined in the BOM, but this isn’t supported when using the Kotlin DSL.

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 and other miscellaneous 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.0"
    
    // Delete this - it's no longer necessary!
    //id("io.spring.dependency-management") version "1.1.5"
}

dependencies {
    implementation(platform(SpringBootPlugin.BOM_COORDINATES))
    
    // Look mom no versions!
    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.1"))
}

You can learn more about platforms and BOM support in the Gradle documentation: https://docs.gradle.org/current/userguide/platforms.html#sub:bom_import

8. Auto-provision Java using toolchains

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

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:

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

You can learn more about Java toolchains in the Gradle documentation: https://docs.gradle.org/current/userguide/toolchains.html