Chapter 02Lesson 02~105 minutes

Java and JVM Project Structure, Source Sets, Compilation, Testing, Packaging, and Toolchains: Guided Hands-On Workflow and Core Operations

Build and inspect a small Java project through Maven and Gradle, following production sources, tests, resources, class files, reports, JAR contents, compiler release targets, and selected toolchains.

MavenGradleJUnitJAR InspectionBytecode

Learning objectives

  • Create conventional production/test Java and resource trees in disposable Maven and Gradle sibling projects.
  • Run compilation, testing, and packaging through project-owned build entry points and inspect the generated filesystem state.
  • Verify test counts/reports, production JAR contents, and bytecode target instead of treating a zero exit code as sufficient evidence.
  • Inspect Maven/Gradle runtime JDK identity and project toolchain selection separately.
  • Repeat the build under an alternate compatible JDK/toolchain when one is already available, without requiring a paid or hosted service.
Version baseline — verified 2026-08-23. Labs use JDK 21 as the common build-runtime JDK, Apache Maven 3.9.16, Maven Compiler Plugin 3.15.0, Maven Toolchains Plugin 3.2.0 where toolchain discovery is demonstrated, Gradle 9.7.1, and JUnit Jupiter 5.13.4. Production bytecode is deliberately targeted to Java 17 with the compiler --release mechanism so the lesson can separate “JDK that runs the build” from “Java release the artifact targets.” Maven 4 preview behavior is not required in this chapter. Re-check current versions before reusing the examples in production.

1. Lab contract and preflight

Create a disposable directory named jvm-structure-lab. Inside it, keep Maven and Gradle projects as siblings so their generated directories and local state do not collide. The source code is intentionally tiny; the lesson is about tracing state, not application complexity.

Network boundary: the first build may resolve plugins and JUnit from Maven Central. Use only official/default repositories in this lab. If you are offline, read the expected output and perform the JDK-only compile/inspection steps; do not add random mirrors to “make it work.”
java -version
javac -version
# From an existing Maven wrapper project:
./mvnw -v
# From an existing Gradle wrapper project:
./gradlew -version

If wrappers are not present yet, a trusted system Maven/Gradle installation may bootstrap them; Chapter 04 and Chapter 15 teach wrapper creation and integrity in depth. Do not download wrapper scripts from arbitrary third-party snippets.

2. Create the shared source/resource contract

Create the same authored inputs in each sibling project. Keeping inputs equivalent makes the differences in generated state easier to understand.

package academy.greeting;

public final class Greeter {
    private Greeter() {}

    public static String message(String name) {
        return "Hello, " + name + "!";
    }
}
package academy.greeting;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;

public final class Main {
    public static void main(String[] args) throws IOException {
        Properties p = new Properties();
        try (InputStream in = Main.class.getResourceAsStream("/app.properties")) {
            if (in == null) throw new IllegalStateException("app.properties missing");
            p.load(in);
        }
        System.out.println(Greeter.message(p.getProperty("name", "learner")));
    }
}
package academy.greeting;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class GreeterTest {
    @Test
    void formatsGreeting() {
        assertEquals("Hello, DevOps!", Greeter.message("DevOps"));
    }
}
name=DevOps

Expected invariant: GreeterTest can see Greeter, but neither Greeter nor Main can depend on test-only classes.

3. Maven track — declare Java 17 output while running Maven on JDK 21

The POM pins the compiler plugin and Surefire, sets the compiler release target to 17, and gives JUnit test scope. It does not put JUnit on the production compile/runtime path.

<?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>academy.lab</groupId>
  <artifactId>jvm-structure-maven</artifactId>
  <version>1.0.0</version>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>17</maven.compiler.release>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</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.3</version>
      </plugin>
    </plugins>
  </build>
</project>
./mvnw -v
./mvnw clean test package
find target -maxdepth 3 -type f | sort
cat target/surefire-reports/*.txt
jar tf target/jvm-structure-maven-1.0.0.jar
javap -verbose target/classes/academy/greeting/Greeter.class | grep "major version"

Expected evidence: one executed JUnit test, production classes under target/classes, test classes under target/test-classes, app.properties in main output and in the JAR, but GreeterTest.class absent from the production JAR. The class-file major version should be 61 for a Java 17 target.

4. Inspect Maven’s JDK/toolchain boundary

./mvnw -v shows the Java runtime that launches Maven. Toolchain discovery is a separate view. The current Maven Toolchains Plugin can display JDKs it discovers without forcing the build to select a different one.

./mvnw -v
./mvnw org.apache.maven.plugins:maven-toolchains-plugin:3.2.0:display-discovered-jdk-toolchains

If only JDK 21 is installed, that is sufficient for the mandatory lab: the compiler release is still 17. If JDK 17 and 21 are both installed, an optional extension may configure Maven Toolchains to select one deliberately; do not hard-code another person’s JDK path into the committed POM.

5. Gradle track — toolchain 21, bytecode/API release 17

The Gradle Java plugin creates main and test source sets. The project requests a Java 21 toolchain for JVM tasks, while options.release = 17 constrains compilation output. This intentionally demonstrates that the toolchain and release target solve different problems.

plugins {
    java
}

repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.13.4")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}
rootProject.name = "jvm-structure-gradle"
./gradlew -version
./gradlew javaToolchains
./gradlew clean test jar
find build -maxdepth 5 -type f | sort
cat build/test-results/test/TEST-*.xml
jar tf build/libs/jvm-structure-gradle.jar
javap -verbose build/classes/java/main/academy/greeting/Greeter.class | grep "major version"

Expected evidence mirrors the conceptual contract, not the Maven directory names: production classes in build/classes/java/main, tests in build/classes/java/test, resources in main output, XML/HTML test reports, and a production JAR without test classes.

6. Run from exploded classes before treating packaging as correct

Running from the generated main output verifies that production resources are on the runtime classpath. The exact classpath syntax is OS-specific; the simplest portable check is to execute through each build tool’s configured application support in later chapters, but for this lab you can inspect and use the output directory directly.

java -cp target/classes academy.greeting.Main
# Expected: Hello, DevOps!
java -cp build/classes/java/main:build/resources/main academy.greeting.Main
# Expected: Hello, DevOps!
Windows note: use ; instead of : between classpath entries in Windows cmd/PowerShell. Maven copied the resource into target/classes, while Gradle keeps main resources in a sibling output directory by default.

7. Optional second-JDK experiment

If your machine already has two compatible JDKs, do not change the source. First record both installations. Then make the build tool select the alternate JDK through its supported toolchain mechanism while keeping release 17. The expected artifact target remains Java 17 even though the compiler implementation may come from a different JDK.

If you have only one JDK, skip this extension. The course does not require downloading a second JDK merely to complete Chapter 02.

8. Verification matrix

Claim Maven evidence Gradle evidence
build runtime JDK identified ./mvnw -v ./gradlew -version
source/test separation target/classes vs target/test-classes build/classes/java/main vs .../test
tests actually executed Surefire report with test count JUnit XML/HTML test result
resource packaged jar tf target/*.jar jar tf build/libs/*.jar
Java 17 bytecode javap -verbose major 61 same
toolchain/JDK candidates inspected Toolchains Plugin display goal javaToolchains

9. Challenge — change one input and predict every affected output

Change app.properties from name=DevOps to name=BuildEngineer. Before rebuilding, predict which generated files should change and which should not. The Java source bytecode should not need semantic recompilation because the Java source is unchanged; the processed resource and packaged JAR should change. Then verify with file timestamps or checksums and artifact inspection.

Knowledge check

Why does JUnit use Maven test scope or Gradle testImplementation instead of a production dependency declaration?

The build runs on JDK 21 and javap reports major version 61. What does that prove?

A test report directory exists but contains zero executed tests. Is the build verified?

Why inspect the JAR after tests pass?

What should you do if first-run dependency resolution fails offline?

Summary

You created the same logical Java project through Maven and Gradle, proved production/test separation, verified test execution, inspected generated directories and JAR contents, and demonstrated a Java 17 artifact target while the build runtime remains JDK 21. The key skill is evidence mapping: every source/resource/classpath/toolchain decision must have an observable output.

Next lesson

Choose the layout and compatibility contract deliberately

Lesson 3 turns the mechanics into design decisions: when to keep conventions, when to customize source sets, how to choose library/application packaging, and how to manage toolchains across developer and CI environments.

Official references and version notes

These lessons were finalized against current primary documentation on 2026-08-23. Build-tool, plugin, JDK, repository, and IDE behavior is version-sensitive; verify the exact versions used by your project and CI before applying a production policy.

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.