Chapter 30Lesson 02~390 minutes

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.

Side-by-side buildDependency graphsTest evidenceJAR inspectionMigration checklist

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?

If Maven and Gradle JAR SHA-256 values differ, what should you do first?

Why is implementation appropriate for Commons Lang in this fixture?

What does Gradle Build Init guarantee after converting a POM?

Which Gradle lifecycle task better matches a Maven CI lane that must both verify and package a Java library?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.