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.
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.
--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.
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!
; 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?
Because the test framework is needed to compile/run tests but should not become part of the production dependency contract merely because tests use it.
The build runs on JDK 21 and javap reports major
version 61. What does that prove?
It proves the inspected class targets Java 17 bytecode. It does not prove the deployment host actually has Java 17 or that every dependency is compatible with it.
A test report directory exists but contains zero executed tests. Is the build verified?
No. The course requires test-count evidence; a successful process with zero intended tests can be a configuration failure.
Why inspect the JAR after tests pass?
Tests and packaging are separate transformations. Resource inclusion, manifest/metadata, and accidental test-class leakage can still be wrong after successful tests.
What should you do if first-run dependency resolution fails offline?
Do not add an untrusted repository. Use cached/approved dependencies if available or complete the local JDK-only inspection path and retry when the approved repository is reachable.
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.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.