Spring Boot – Building with Gradle

August 14, 20265 min readUpdated 8/18/2026

Maven and Gradle both build Spring Boot applications, and Spring supports both equally. This lesson is the pizza API's pom.xml translated into Gradle, side by side, so you can read either build file and know what the other one says.

Two files instead of one

// settings.gradle.kts
//
// The project name Gradle uses for the built artifact. Maven takes this from
// <artifactId>; in Gradle it lives here, NOT in build.gradle.kts, because it has to be
// known before the build script is evaluated.
rootProject.name = "pizza-springboot-backend"

That surprises people coming from Maven: the artifact name is not in the build script. It has to be known before the script is evaluated, so it lives in settings.gradle.kts.

Plugins replace the parent

plugins {
    java

    // Packages the app as an executable jar and wires in `bootRun`. This is the plugin
    // that corresponds to spring-boot-maven-plugin.
    id("org.springframework.boot") version "4.1.0"

    // The dependency-management plugin is what gives Gradle Maven's <parent> behaviour:
    // it applies the Spring Boot BOM so versions can be omitted below. Without it every
    // `implementation("org.springframework.boot:...")` line would need an explicit
    // version, which is the single most common difference between a Maven pom and a
    // Gradle script people port by hand.
    id("io.spring.dependency-management") version "1.1.7"

    id("com.diffplug.spotless") version "7.0.2"
}

Maven's <parent> does two jobs: it supplies the plugin and it supplies the managed versions. Gradle splits them, and forgetting the second one is the classic porting mistake — every dependency then demands an explicit version.

Toolchains beat java.version

java {
    toolchain {
        // A toolchain, not `sourceCompatibility`. This makes Gradle FIND (or download) a
        // JDK 21 rather than assuming whichever JVM happens to be running the build, so
        // the build is reproducible on a machine whose default java is 17 or 25.
        languageVersion = JavaLanguageVersion.of(21)
    }
}

This is genuinely better than Maven's <java.version>, which only sets the compiler's source and target levels while still compiling against whatever JDK is running. A toolchain makes the JDK itself part of the build definition.

Dependency scopes

MavenGradleMeans
compile (default)implementationneeded to compile and run
runtimeruntimeOnlyneeded to run only
providedcompileOnlyneeded to compile only
testtestImplementationtests
apileaks onto consumers' compile classpath
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-liquibase")

// Boot 4 renamed spring-boot-starter-aop to spring-boot-starter-aspectj.
implementation("org.springframework.boot:spring-boot-starter-aspectj")
implementation("org.springframework.boot:spring-boot-starter-cache")

// `runtimeOnly` is Gradle's <scope>runtime</scope>: the driver is needed to RUN but
// nothing should compile against it, and Gradle enforces that where Maven only asks.
runtimeOnly("com.mysql:mysql-connector-j")

implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion")
runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion")

implementation versus api is Gradle's real advantage. Maven puts every compile dependency on your consumers' compile classpath, so their code can accidentally use your transitive dependencies and break when you upgrade one. implementation hides them, which makes builds faster and upgrades safer.

Annotation processors

implementation("org.mapstruct:mapstruct:$mapstructVersion")

// `compileOnly` + `annotationProcessor` is the Gradle spelling of Maven's
// <optional>true</optional> on Lombok: on the compile classpath, absent from the jar.
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")

// ⚠️ ORDER IS LOAD-BEARING, exactly as in the pom. Lombok must run before MapStruct,
// and lombok-mapstruct-binding is what lets MapStruct see the accessors Lombok
// generated. Get it wrong and the build still succeeds — it just produces mappers
// that map nothing, which is a genuinely horrible afternoon.
annotationProcessor("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion")
annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion")

Same trap as lesson 19, spelled differently. Declaration order in this block is the processing order.

developmentOnly has no Maven equivalent — it keeps devtools out of the packaged jar automatically, where Maven needs <optional> plus a plugin exclusion.

Tests need telling

testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.security:spring-security-test")

// Gradle does not put JUnit's launcher on the test runtime classpath automatically.
// Leaving this out produces "Please make sure that the JUnit Platform is on the
// classpath", which sounds like a missing test framework rather than a missing runner.
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")
tasks.withType<Test> {
    // Maven's Surefire picks JUnit 5 up on its own; Gradle has to be told.
    useJUnitPlatform()
}

Forgetting useJUnitPlatform() is the most common Gradle-and-JUnit-5 problem: the build succeeds and reports zero tests, which is far worse than failing. Note also that Lombok needs declaring separately for test sources — testCompileOnly and testAnnotationProcessor — because Gradle's source sets are independent.

⚠️ Boot 4.1 needs Gradle 8.14+

Spring Boot plugin requires Gradle 8.x (8.14 or later) or 9.x.
The current version is Gradle 8.12

That message names the version and not the fix, and the fix is not "upgrade the Gradle on your machine" — it is to stop depending on the Gradle on your machine at all:

gradle wrapper --gradle-version 8.14.3

Which generates gradlew, gradlew.bat and gradle/wrapper/. Commit all four, and invoke ./gradlew rather than gradle. The wrapper downloads the pinned version on first use, so everyone and every CI runner builds with the same Gradle — the same argument as ./mvnw.

One packaging trap: many .gitignore files ignore *.jar, which silently excludes gradle-wrapper.jar and leaves the wrapper unable to bootstrap on a fresh clone. The negation is required, not optional:

*.jar
!gradle/wrapper/gradle-wrapper.jar

The commands

MavenGradle
./mvnw compile./gradlew compileJava
./mvnw test./gradlew test
./mvnw package./gradlew build
./mvnw spring-boot:run./gradlew bootRun
./mvnw clean./gradlew clean
./mvnw spotless:apply./gradlew spotlessApply
./mvnw dependency:tree./gradlew dependencies

./gradlew build runs check too, so it compiles, tests, runs Spotless and produces the jar in one go — closer to mvn verify than to mvn package.

Which should you choose?

Gradle is faster on incremental builds — a build cache and a daemon that stays warm — and its implementation/api distinction genuinely improves large multi-module projects. The cost is that a build script is a program, and programs can become inscrutable.

Maven is declarative and boring, which is a real feature. Any Java developer can read a pom.xml; not every Java developer can read a 400-line Kotlin DSL script with custom tasks.

For a single-module service like the pizza API, the difference barely matters — which is why it stays on Maven, with this Gradle build maintained alongside on its own branch so both stay honest. For a large multi-module build with slow tests, Gradle's incrementality is worth real money.

Do not run both in one directory. Two build files makes IDEs ask which to import, and importing the wrong one fails confusingly.

Groovy or Kotlin DSL?

Everything above is build.gradle.kts, the Kotlin DSL — type-safe, with real IDE completion and refactoring. The Groovy DSL (build.gradle) is more concise and still very common. Kotlin is the better default for new projects; the concepts are identical.

What to take from this

  • io.spring.dependency-management replaces the parent's version management. Omit it and every dependency needs a version.
  • Toolchains, not sourceCompatibility — the JDK becomes part of the build.
  • compileOnly + annotationProcessor for Lombok, and the processor order still matters.
  • useJUnitPlatform(), or you get a green build that ran no tests.
  • Commit the wrapper, and un-ignore gradle-wrapper.jar.

Next: the cheat sheet — everything in this track on one page.