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
| Maven | Gradle | Means |
|---|---|---|
compile (default) | implementation | needed to compile and run |
runtime | runtimeOnly | needed to run only |
provided | compileOnly | needed to compile only |
test | testImplementation | tests |
| — | api | leaks 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.12That 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.3Which 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.jarThe commands
| Maven | Gradle |
|---|---|
./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-managementreplaces the parent's version management. Omit it and every dependency needs a version.- Toolchains, not
sourceCompatibility— the JDK becomes part of the build. compileOnly+annotationProcessorfor 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.