Chapter 30Lesson 05~430 minutes

Checkpoint Lab — Maven vs Gradle: Model Differences, Migration Strategies, Mixed Estates, and Tool Selection

Execute a bounded migration plan for a representative library, define invariants before changing build files, compare Maven and Gradle evidence, inject and repair one dependency mismatch, document non-equivalence honestly, and produce a rollback-safe tool recommendation.

CheckpointMigration planRollbackEquivalence invariantsChapter 31 bridge

Learning objectives

  • Write migration invariants before changing build configuration.
  • Execute side-by-side Maven/Gradle builds and compare compile, tests, dependency graph, artifact payload, metadata, and CI entry point.
  • Inject and diagnose a dependency mismatch using each tool's native evidence.
  • Document raw-byte and metadata non-equivalence instead of forcing false equality.
  • Produce an explicit rollback path and a justified Maven/Gradle/mixed-estate recommendation.

Checkpoint rule. The existing Maven build remains authoritative until the Gradle candidate passes the defined invariants. Do not publish the candidate under a real production coordinate or modify shared repositories.

1. Scenario and acceptance contract

You maintain a small Maven library consumed by JVM services. The team is considering Gradle because other repositories already use tested Gradle conventions and caches. Your assignment is not “convert the POM.” It is to determine whether this library can migrate without changing its delivery contract.

Invariant Required proof
Source/API Same reviewed source and public classes/methods.
Toolchain Maven 3.9.16 / Gradle 9.7.1 wrappers recorded; JDK 21 compiler environment; Java 17 target.
Dependencies Commons Lang resolves to 3.20.0 in both; consumer exposure differences are understood.
Tests Exactly two fixture tests run and fail the build on failure.
Artifact Required class/resource payload present; raw SHA-256 recorded; any byte difference explained.
Metadata Same GAV; Maven-consumer scopes are intentional; Gradle-only metadata does not become an unreviewed requirement.
CI entry Maven clean verify and Gradle clean build produce the required evidence.
Publication Only the authoritative pipeline may publish the immutable release coordinate.

2. Preflight and exact assumptions

Use a disposable ch30-checkpoint directory. Preconditions:

  • Maven Wrapper 3.3.4 pinned to Maven 3.9.16;
  • Gradle Wrapper pinned to Gradle 9.7.1;
  • JDK 21 available to run both tools and compile;
  • Java --release 17 target in both build models;
  • Maven Compiler 3.15.0, Surefire 3.5.6, JAR 3.5.1;
  • Commons Lang 3.20.0 and JUnit 6.1.3;
  • lab-local Maven repository and Gradle User Home;
  • no production credentials, repository writes, or global cache deletion.

If dependencies/wrapper distributions are unavailable because the lab is offline and not already cached, record the blocked cells rather than weakening version/security policy.

3. Create the authoritative Maven baseline and Gradle candidate

Create the same source/test fixture from Lesson 2 in both directories. Keep the build descriptors native:

<?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>
rootProject.name = "greeting-lib"
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")
            }
        }
    }
}

Copy identical GreetingFormatter.java and GreetingFormatterTest.java into both source trees. Before execution, predict:

  1. both builds will select Commons Lang 3.20.0 and run two tests;
  2. the application class bytes should match under the same compiler/toolchain contract, but whole-JAR SHA-256 may differ because archive metadata/model implementation differs;
  3. the Gradle-generated Maven POM may expose Commons Lang differently because the candidate uses implementation.

4. Build the authoritative Maven baseline and capture evidence

From maven/:

mkdir -p ../evidence/maven
./mvnw --version | tee ../evidence/maven/tool.txt
java -version 2> ../evidence/maven/java.txt
./mvnw -Dmaven.repo.local="$PWD/.checkpoint-m2" clean verify | tee ../evidence/maven/build.log
./mvnw -Dmaven.repo.local="$PWD/.checkpoint-m2" \
  org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree \
  > ../evidence/maven/dependency-tree.txt
jar tf target/greeting-lib-1.0.0.jar > ../evidence/maven/jar-entries.txt
sha256sum target/greeting-lib-1.0.0.jar > ../evidence/maven/jar.sha256
cp target/surefire-reports/*.xml ../evidence/maven/

This evidence is the rollback anchor. Do not delete or rewrite it when the candidate fails.

5. Build the Gradle candidate and capture equivalent evidence

From gradle/:

mkdir -p ../evidence/gradle
export GRADLE_USER_HOME="$PWD/.checkpoint-gradle"
./gradlew --version | tee ../evidence/gradle/tool.txt
java -version 2> ../evidence/gradle/java.txt
./gradlew clean build | tee ../evidence/gradle/build.log
./gradlew dependencies --configuration runtimeClasspath > ../evidence/gradle/dependency-tree.txt
./gradlew dependencyInsight --dependency commons-lang3 --configuration runtimeClasspath \
  > ../evidence/gradle/dependency-insight.txt
./gradlew generatePomFileForMavenJavaPublication
jar tf build/libs/greeting-lib-1.0.0.jar > ../evidence/gradle/jar-entries.txt
sha256sum build/libs/greeting-lib-1.0.0.jar > ../evidence/gradle/jar.sha256
cp build/publications/mavenJava/pom-default.xml ../evidence/gradle/generated-pom.xml
cp build/test-results/test/*.xml ../evidence/gradle/

If the Gradle candidate cannot run, that is a checkpoint result—not permission to alter the Maven baseline or unpin versions.

6. Compare evidence by invariant

Dependency version:

grep -n "commons-lang3" evidence/maven/dependency-tree.txt || true
grep -n "commons-lang3" evidence/gradle/dependency-tree.txt || true

Test counts:

grep -h "tests=\"" evidence/maven/*.xml | head -n 5
grep -h "tests=\"" evidence/gradle/*.xml | head -n 5

JAR entries and hashes:

diff -u evidence/maven/jar-entries.txt evidence/gradle/jar-entries.txt || true
cat evidence/maven/jar.sha256
cat evidence/gradle/jar.sha256

Generated consumer metadata:

grep -nE "groupId|artifactId|version|scope" maven/pom.xml
grep -nE "groupId|artifactId|version|scope" evidence/gradle/generated-pom.xml

7. Independently compare compiled application class bytes

Extract each JAR and compare the class that implements the public behavior:

rm -rf evidence/unpacked && mkdir -p evidence/unpacked/maven evidence/unpacked/gradle
(cd evidence/unpacked/maven && jar xf ../../../maven/target/greeting-lib-1.0.0.jar)
(cd evidence/unpacked/gradle && jar xf ../../../gradle/build/libs/greeting-lib-1.0.0.jar)

sha256sum evidence/unpacked/maven/dev/academy/migration/GreetingFormatter.class
sha256sum evidence/unpacked/gradle/dev/academy/migration/GreetingFormatter.class

javap -verbose evidence/unpacked/maven/dev/academy/migration/GreetingFormatter.class \
  | grep "major version"
javap -verbose evidence/unpacked/gradle/dev/academy/migration/GreetingFormatter.class \
  | grep "major version"

Expected target is class-file major 61 for Java 17. If application class bytes match but whole-JAR hashes differ, the non-equivalence is likely packaging metadata; inspect manifests/entry timestamps before deciding whether byte identity is required by your release process.

8. Inject a dependency mismatch and prove the gate catches it

Change only the Gradle candidate's Commons Lang dependency from 3.20.0 to 3.19.0. Predict: the Gradle dependency graph changes while the Maven baseline does not. Capture the mismatch:

# After intentionally editing only gradle/build.gradle.kts:
cd gradle
export GRADLE_USER_HOME="$PWD/.checkpoint-gradle"
./gradlew dependencyInsight --dependency commons-lang3 --configuration runtimeClasspath \
  > ../evidence/gradle/dependency-insight-broken.txt
cd ..

grep -n "commons-lang3" evidence/maven/dependency-tree.txt || true
grep -n "commons-lang3" evidence/gradle/dependency-insight-broken.txt || true

Do not repair with an exclusion or forced resolution rule merely to hide the evidence. Restore the intended 3.20.0 declaration, rerun dependencyInsight, then rebuild the candidate in controlled state.

9. Prove the CI entry point preserves the required outcome

Record the intended commands in a small repository-owned migration note:

Maven authoritative lane:
  ./mvnw -Dmaven.repo.local=.ci-m2 clean verify

Gradle candidate lane:
  GRADLE_USER_HOME=.ci-gradle ./gradlew clean build

Required evidence from either lane:
  - exact Wrapper/build-tool/JDK identity
  - dependency graph
  - two-test report set
  - packaged JAR
  - JAR checksum
  - Maven-consumer publication metadata

Now deliberately run ./gradlew clean check in a clean candidate workspace and verify that you do not assume packaging from the task name. Restore build as the candidate CI entry point when packaging is part of the contract.

10. Write the non-equivalence register

A professional migration report explicitly lists accepted and rejected differences. Example:

Difference Observed? Disposition
Maven and Gradle dependency report formatting differs. Yes Accept; selected coordinate/version and consumer graph are the invariant.
Whole JAR SHA-256 differs. Possible Investigate manifest/archive metadata; do not dual-publish same immutable version. Decide whether byte identity is required.
Compiled application class SHA-256 differs. Should not without explanation Block migration until compiler/toolchain/source/generated-input difference is understood.
Gradle generated POM narrows Commons Lang to runtime due implementation. Expected in this fixture Accept only after clean consumer/public API verification.
Test count differs. No Block migration; false-green risk.
Gradle adds Module Metadata if published. Expected Gradle capability Accept if Maven consumers remain supported and richer semantics do not become hidden mandatory requirements.

11. Rollback plan before cutover

Rollback is simple because the Maven baseline was never destroyed:

  1. keep Maven Wrapper/POM/CI lane authoritative;
  2. keep Gradle files/candidate CI lane isolated or remove them from the migration branch;
  3. do not publish any Gradle-built candidate under the production release coordinate;
  4. retain evidence explaining why the candidate was rejected;
  5. fix one semantic mismatch at a time and rerun the checklist.

At cutover time, switch the single publication writer explicitly. Rollback then means restoring that writer and the old verified build lane—not recreating old build logic from memory.

12. Produce a justified tool-selection recommendation

Finish with one of three recommendations: stay on Maven, migrate to Gradle, or operate a governed mixed estate. Justify it with evidence in this format:

Question Evidence to cite
Does the candidate preserve behavior? Dependency/test/class/metadata/CI equivalence checklist.
Is there a measured benefit? Representative local/CI timings with controlled cache/toolchain/test state.
What new complexity appears? Gradle convention plugins/caches/variant metadata, or Maven plugin/lifecycle constraints.
Can the platform support it? Team skill, wrapper/JDK baselines, plugin support, security/repository policy.
What is the rollback cost? Single publication writer, side-by-side build retention, reversible CI cutover.
What is the long-term governance plan? Version support windows, upgrade testing, artifact interoperability, shared delivery controls.

13. Final verification checklist

Evidence Pass condition
Exact files/models Maven POM and Gradle settings/build scripts reviewed; no hidden production settings/init scripts.
Tool identity Maven 3.9.16 / Wrapper 3.3.4 and Gradle 9.7.1 Wrapper evidence captured; JDK 21 recorded.
Compatibility Both compile with Java 17 target; class major version verified.
Dependencies Commons Lang 3.20.0 after repair; no unexplained graph drift.
Tests Two tests in each lane; no skipped/filtered-zero false green.
Artifact Required class payload present; raw checksums recorded; differences explained.
Metadata Same GAV; consumer scope differences intentional and tested.
CI Maven verify versus Gradle build mapping documented by required outcomes.
Publication One authoritative writer for immutable coordinate.
Rollback Old verified build and CI path remains recoverable until cutover acceptance.

14. Cleanup and bridge to Chapter 31

Delete only disposable checkpoint caches/workspaces after preserving the evidence report:

rm -rf maven/.checkpoint-m2 gradle/.checkpoint-gradle evidence/unpacked
# Remove the entire checkpoint workspace only when you no longer need its evidence.
# rm -rf ch30-checkpoint

What Chapter 30 adds: you can now treat build-tool migration as an engineering change with explicit invariants, evidence, rollback, and governance. Chapter 31 combines the whole course into a production capstone: build, test, secure, publish, and optimize a multi-module JVM platform with an operational handoff.

Knowledge check

Why must invariants be written before migration edits?

Which mismatch is intentionally injected in the checkpoint?

Can behavioral equivalence be accepted if the whole-JAR SHA-256 differs?

Which difference would normally block the migration immediately?

What makes rollback low risk in this checkpoint?

What does Chapter 31 build on top of this?

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.