Maven vs Gradle: Model Differences, Migration Strategies, Mixed Estates, and Tool Selection: Guided Hands-On Workflow and Core Operations
Build one small JVM library with Maven and Gradle side by side, capture tool/dependency/test/artifact/metadata evidence, and use an equivalence checklist to separate acceptable representation differences from real behavioral regressions.
Learning objectives
- Create side-by-side Maven and Gradle copies of one JVM library with identical source/test inputs.
- Run wrapper-first clean builds with isolated local dependency state and the same Java 17 compatibility contract.
- Compare dependency graphs, test reports, JAR payloads/checksums, and Maven-compatible metadata.
- Use Gradle Build Init as an optional migration starting point without mistaking generated syntax for verified equivalence.
- Keep a migration evidence checklist and make one bounded design decision based on observed behavior.
Lab boundary. Run only in a disposable directory.
The examples isolate Maven local-repository state and Gradle User
Home state under the lab. Do not delete normal ~/.m2 or
~/.gradle directories.
1. Create the two-build workspace
Create two project directories from the same source template. Each directory receives its own native build model; the source and tests are intentionally identical.
mkdir -p ch30-lab/template/src/main/java/dev/academy/migration
mkdir -p ch30-lab/template/src/test/java/dev/academy/migration
mkdir -p ch30-lab/maven ch30-lab/gradle
cd ch30-lab
Place the following Java source in
template/src/main/java/dev/academy/migration/GreetingFormatter.java:
package dev.academy.migration;
import org.apache.commons.lang3.StringUtils;
public final class GreetingFormatter {
private GreetingFormatter() {}
public static String greet(String rawName) {
String normalized = StringUtils.defaultIfBlank(rawName, "world").trim();
return "Hello, " + StringUtils.capitalize(normalized) + "!";
}
}
Place this test in
template/src/test/java/dev/academy/migration/GreetingFormatterTest.java:
package dev.academy.migration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class GreetingFormatterTest {
@Test void greetsNamedUser() {
assertEquals("Hello, Ada!", GreetingFormatter.greet("ada"));
}
@Test void defaultsBlankName() {
assertEquals("Hello, World!", GreetingFormatter.greet(" "));
}
}
Copy the source tree into both builds:
cp -R template/src maven/
cp -R template/src gradle/
2. Preflight: prove tool and JDK identity before comparing outcomes
The lab assumes a committed Maven Wrapper
3.3.4 targeting Maven 3.9.16 in
maven/, and a committed Gradle Wrapper targeting
9.7.1 in gradle/. Use wrappers
created/verified with the Chapter 4 and Chapter 15 procedures. Do
not bootstrap from unreviewed scripts.
cd maven
./mvnw --version
java -version
cd ../gradle
./gradlew --version
java -version
cd ..
Expected model: both build tools run on the approved JDK 21 environment. Both compilers target Java 17. The build-tool runtime and artifact runtime compatibility are related but separate identities.
3. Author the Maven model and build it
Write maven/pom.xml. The Compiler, Surefire, and JAR
plugin versions are pinned so plugin drift does not contaminate the
comparison:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.academy.migration</groupId>
<artifactId>greeting-lib</artifactId>
<version>1.0.0</version>
<name>Greeting Library</name>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
<commons-lang3.version>3.20.0</commons-lang3.version>
<junit.version>6.1.3</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
</plugin>
</plugins>
</build>
</project>
Run from maven/ with a lab-local repository:
cd maven
./mvnw -Dmaven.repo.local="$PWD/.lab-m2" clean verify
./mvnw -Dmaven.repo.local="$PWD/.lab-m2" \
org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree \
> ../evidence-maven-tree.txt
cp target/surefire-reports/*.xml ../ 2>/dev/null || true
jar tf target/greeting-lib-1.0.0.jar > ../evidence-maven-jar.txt
sha256sum target/greeting-lib-1.0.0.jar > ../evidence-maven-sha256.txt
cd ..
What changed? Maven read the POM, resolved
plugin/dependency metadata into maven/.lab-m2, compiled
classes to target/classes, ran tests through Surefire,
then packaged a JAR before reaching verify. The
dependency tree and test reports are independent evidence; a
successful final exit code alone is not enough.
4. Author the Gradle model and build it
Write gradle/settings.gradle.kts:
rootProject.name = "greeting-lib"
Write gradle/build.gradle.kts:
plugins {
`java-library`
`maven-publish`
}
group = "dev.academy.migration"
version = "1.0.0"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
dependencies {
implementation("org.apache.commons:commons-lang3:3.20.0")
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
}
tasks.test {
useJUnitPlatform()
}
tasks.jar {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
pom {
name.set("Greeting Library")
}
}
}
}
Run from gradle/ with an isolated Gradle User Home:
cd gradle
export GRADLE_USER_HOME="$PWD/.lab-gradle"
./gradlew clean build
./gradlew dependencies --configuration runtimeClasspath > ../evidence-gradle-tree.txt
./gradlew dependencyInsight --dependency commons-lang3 --configuration runtimeClasspath \
> ../evidence-gradle-insight.txt
./gradlew generatePomFileForMavenJavaPublication
cp build/test-results/test/*.xml ../ 2>/dev/null || true
jar tf build/libs/greeting-lib-1.0.0.jar > ../evidence-gradle-jar.txt
sha256sum build/libs/greeting-lib-1.0.0.jar > ../evidence-gradle-sha256.txt
cd ..
What changed? Gradle configured the project,
selected the build task graph, resolved the
runtime/test configurations into the isolated Gradle User Home, ran
the test task, assembled the JAR, and generated Maven
publication metadata from the Java component. No Maven lifecycle was
involved.
5. Compare dependency graphs by selected coordinates, not formatting
The reports use different layouts. Normalize the comparison to the dependency identity you care about:
grep -n "commons-lang3" evidence-maven-tree.txt || true
grep -n "commons-lang3" evidence-gradle-tree.txt || true
grep -n "commons-lang3" evidence-gradle-insight.txt || true
Both builds should select
org.apache.commons:commons-lang3:3.20.0. Do not require
line-by-line report equality: Maven and Gradle expose different
graph concepts and diagnostic detail.
6. Compare test evidence, including actual test counts
Inspect the XML reports rather than treating “BUILD SUCCESSFUL” as
equivalent evidence. Maven Surefire writes under
target/surefire-reports; Gradle writes under
build/test-results/test. For this fixture, both should
execute exactly two tests with zero failures/errors.
grep -h "tests=\"" maven/target/surefire-reports/*.xml | head -n 5
grep -h "tests=\"" gradle/build/test-results/test/*.xml | head -n 5
If either tool reports zero tests, skips the class, or filters differently, the migration is not equivalent even when the compile/package step succeeds.
7. Compare JAR payload first, raw checksum second
Start with the visible payload:
diff -u evidence-maven-jar.txt evidence-gradle-jar.txt || true
cat evidence-maven-sha256.txt
cat evidence-gradle-sha256.txt
Both JARs should contain the compiled
GreetingFormatter.class. The raw SHA-256 values may
differ because ZIP/JAR metadata such as manifest content, entry
ordering, or archive implementation can differ. A checksum
difference is evidence to investigate, not an automatic migration
failure and never a reason to overwrite one immutable release with
the other.
For a stronger semantic payload comparison, extract and compare the application class bytes:
rm -rf compare && mkdir -p compare/maven compare/gradle
(cd compare/maven && jar xf ../../maven/target/greeting-lib-1.0.0.jar)
(cd compare/gradle && jar xf ../../gradle/build/libs/greeting-lib-1.0.0.jar)
sha256sum compare/maven/dev/academy/migration/GreetingFormatter.class
sha256sum compare/gradle/dev/academy/migration/GreetingFormatter.class
cmp compare/maven/dev/academy/migration/GreetingFormatter.class \
compare/gradle/dev/academy/migration/GreetingFormatter.class || true
If class bytes differ, use javap -verbose and
compiler/toolchain evidence to explain why before accepting the
migration.
8. Compare Maven consumer metadata explicitly
The Maven source model is maven/pom.xml. Gradle's Maven
Publish plugin generated
gradle/build/publications/mavenJava/pom-default.xml.
Compare coordinates and dependency scopes:
grep -nE "groupId|artifactId|version|scope" maven/pom.xml
grep -nE "groupId|artifactId|version|scope" gradle/build/publications/mavenJava/pom-default.xml
A meaningful difference is expected: the Gradle build declares
Commons Lang as implementation, so Maven-compatible
publication metadata normally treats it as a runtime dependency
rather than an API dependency. That is safe only because the
fixture's public API does not expose Commons Lang types. The
migration decision must be based on consumer behavior, not on a
desire to make XML look identical.
9. Compare CI entry points by outcomes
For this simple library, these are reasonable baseline CI entry points:
| Tool | Entry point | Required outcome |
|---|---|---|
| Maven |
./mvnw -Dmaven.repo.local=.ci-m2 clean verify
|
Compile + test + package + verify under pinned Maven/JDK state. |
| Gradle |
GRADLE_USER_HOME=.ci-gradle ./gradlew clean build
|
Compile + test/check + assemble under pinned Gradle/JDK state. |
Do not replace Maven verify with Gradle
check unless the pipeline does not require a packaged
artifact. In the Java plugin, build is the lifecycle
task that combines checking and assembling.
10. Optional: use Gradle Build Init as a starting point, not as proof
Gradle 9.7.1 can convert a valid Maven POM using the
pom build-init type. The current documentation
explicitly warns that Maven and Gradle differ fundamentally and not
every feature converts exactly. Use conversion only in a disposable
copy, then run the same equivalence checklist.
# Run only from a disposable Maven-project copy using a trusted Gradle 9.7.1 installation.
gradle init --type pom --dsl kotlin --no-incubating
# The generated Wrapper/build files become a candidate, not an accepted migration.
./gradlew --version
./gradlew clean build
In particular, dependency exclusions and custom plugin behavior require manual review. Keep the working Maven build side by side until the candidate passes the defined invariants.
11. Challenge: should Commons Lang be api or
implementation?
Do not copy a command. Decide from the model. In the current
fixture, GreetingFormatter uses Commons Lang internally
and exposes only String. Choose
implementation and explain why it reduces consumer
compile coupling. Then imagine the public method returned
org.apache.commons.lang3.tuple.Pair; now the dependency
type appears in the API and should be exposed with Gradle
api if Maven consumers are expected to compile without
declaring Commons Lang themselves.
12. Cleanup
Preserve evidence files if desired, then delete only the lab directory:
cd ..
rm -rf ch30-lab
13. What the guided comparison proved
You now have an evidence model for migration: same source does not imply same dependency exposure, same lifecycle semantics, same metadata, or same archive bytes. Lesson 3 turns those observations into tool-selection and migration-strategy decisions for real teams.
Knowledge check
Why compare selected coordinates instead of diffing Maven and Gradle dependency reports line-for-line?
Because the reports expose different models and formatting. The invariant is the required selected graph/consumer behavior, not textual report identity.
If Maven and Gradle JAR SHA-256 values differ, what should you do first?
Inspect JAR contents, manifests/archive metadata, class/resource hashes, toolchain identity, and reproducibility settings; record the difference before deciding whether it is acceptable.
Why is implementation appropriate for Commons Lang
in this fixture?
The public API exposes only JDK types; Commons Lang is an internal implementation detail and need not be on consumer compile classpaths.
What does Gradle Build Init guarantee after converting a POM?
It generates a useful starting Gradle model, not semantic equivalence. Custom build behavior and dependency semantics still need evidence-driven verification.
Which Gradle lifecycle task better matches a Maven CI lane that must both verify and package a Java library?
Usually build, because it combines
check and assembly; check alone is not
a packaging guarantee.
Official references and version notes
- Apache Maven release history — Maven 3.9.16 GA baseline; Maven 4.0.0-rc-6 remains pre-GA at generation time.
- Apache Maven Wrapper 3.3.4 — current stable Wrapper baseline.
- Maven build lifecycle — lifecycle phases and plugin-goal execution model.
- Maven dependency mechanism — mediation, scopes, dependency management, and BOM concepts.
- Maven Compiler Plugin 3.15.0 — pinned compiler-plugin baseline.
- Maven Surefire 3.5.6 — pinned unit-test execution baseline.
- Maven JAR Plugin 3.5.1 — pinned JAR packaging baseline.
- Gradle 9.7.1 release notes — pinned Gradle baseline.
- Migrating builds from Apache Maven — side-by-side migration and semantic-difference guidance.
- Gradle Build Init plugin — Maven POM conversion support and its limitations.
- Gradle dependency management — configuration/variant-aware resolution model.
- Gradle Maven Publish — generated POM/publication semantics.
Version-sensitive statements were rechecked against primary documentation on 2026-08-24. The mandatory path remains local/free; no hosted CI, repository manager, commercial analytics service, or production credentials are required.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.