Chapter 02Lesson 01~80 minutes

Java and JVM Project Structure, Source Sets, Compilation, Testing, Packaging, and Toolchains: Concepts, Architecture, and Mental Model

Connect Java project layout to source sets, classpaths, compilation outputs, test boundaries, packaging, bytecode targets, and JDK toolchains so every build artifact can be explained from its inputs.

Java Project LayoutSource SetsClasspathsToolchainsPackaging

Learning objectives

  • Map conventional production/test Java sources and resources to separate compiled outputs and runtime classpaths.
  • Distinguish compile classpath, test compile classpath, runtime classpath, and test runtime classpath instead of treating “the classpath” as one list.
  • Separate the JVM that runs Maven/Gradle, the JDK selected as a project toolchain, and the Java release encoded into generated class files.
  • Explain what a JAR packages, what it normally excludes, and why a successful compilation does not by itself prove a runnable deployment artifact.
  • Inspect project/toolchain state before mutating build files or clearing caches.
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. The practical problem: a JVM project is several pipelines sharing one repository

Chapter 01 treated a build as a transformation from declared inputs to verified outputs. The next question is more concrete: which source files, resources, dependencies, compilers, test engines, and runtimes belong to each transformation? A Java repository normally contains at least two logical worlds. Production code must stand on its own. Test code may depend on production code plus test frameworks and fixtures, but production code must not silently depend on test-only classes.

This separation matters in CI because compilation, testing, packaging, and deployment are different evidence points. If the test classpath accidentally leaks into production compilation, a developer may see green tests while the packaged application fails in a clean runtime.

2. Conventional layout is executable build information

Maven and Gradle’s Java ecosystem both recognize the familiar src/main/java, src/main/resources, src/test/java, and src/test/resources convention. Gradle names the logical collections source sets; Maven’s standard directory layout and lifecycle/plugins provide an analogous production/test separation. The vocabulary differs, but the important invariant is the same: production and test inputs have different consumers and should produce different outputs.

jvm-layout-lab/
├── src/
│   ├── main/
│   │   ├── java/academy/greeting/Greeter.java
│   │   └── resources/app.properties
│   └── test/
│       ├── java/academy/greeting/GreeterTest.java
│       └── resources/test.properties
├── pom.xml                  # Maven track
└── build.gradle.kts         # Gradle track (normally in a separate sibling project)
Input Typical consumer Typical generated destination Usually enters production JAR?
src/main/java main compiler Maven target/classes; Gradle build/classes/java/main compiled classes: yes
src/main/resources resource processing / main runtime main output/resources yes
src/test/java test compiler and test runner Maven target/test-classes; Gradle build/classes/java/test no
src/test/resources test runtime test output/resources no

Generated directories are evidence, not source-of-truth input. Commit the build definition and authored source/resources; regenerate target/ and build/.

3. Read the source-to-artifact flow as a graph

Production and test paths converge for verification but not for packaging
flowchart TD
  MJ["src/main/java"] --> MC["compile main"]
  MR["src/main/resources"] --> MO["main output"]
  MC --> MO
  TJ["src/test/java"] --> TC["compile tests"]
  TR["src/test/resources"] --> TO["test output"]
  MO --> TC
  TC --> TO
  MO --> JAR["production JAR"]
  TO --> TEST["test runtime"]
  MO --> TEST
  TD["test dependencies"] --> TEST
  PD["production dependencies"] --> MC
  PD --> TEST

The arrow from main output to test compilation is deliberate: tests normally compile against production classes. There is no reverse arrow from test output to main compilation. The production JAR is assembled from production output, not from test classes merely because those classes existed during the build.

4. “The classpath” is not one thing

A compile classpath is what the compiler may reference while translating source to bytecode. A runtime classpath is what the JVM can load when executing compiled code. Tests add their own compile and runtime layers. A dependency can therefore be available while compiling but absent at runtime, or available only to tests.

Classpath Contains conceptually Failure when wrong
main compile production compile dependencies cannot find symbol / missing package during compilation
main runtime production output + runtime dependencies ClassNotFoundException or NoClassDefFoundError
test compile main output + test compile dependencies tests fail to compile
test runtime main/test output + test runtime dependencies test engine/provider or test runtime failure

Later chapters teach Maven scopes and Gradle configurations in depth. For now, the diagnostic skill is to ask which consumer is missing which class? before changing dependency declarations.

5. Three Java identities: build runtime, compiler/toolchain, artifact target

Suppose CI runs Maven or Gradle on JDK 21 but the production service is standardized on Java 17. That is valid if the build deliberately targets Java 17. The JVM running the build tool and the release encoded in the application’s class files are separate contracts.

With modern javac, --release 17 is stronger than setting only source and target numbers: it constrains language rules, generated class version, and the documented Java SE API surface for that release. Gradle likewise distinguishes the selected toolchain from options.release.

java -version
javac -version
mkdir -p out
javac --release 17 -d out src/main/java/academy/greeting/Greeter.java
javap -verbose out/academy/greeting/Greeter.class | grep "major version"

For a Java 17 class, javap reports class-file major version 61. This proves the emitted class-file level; it does not prove which JVM will later run the artifact.

6. Toolchains make the compiler/runtime choice a build requirement

Maven Toolchains can select a JDK for toolchain-aware plugins independently of the JVM running Maven. Current Maven Toolchains Plugin 3.2.0 can also discover installed JDKs. Gradle’s Java toolchain support can select an installed JDK and, when a resolver is configured, provision one. Toolchains improve reproducibility because the project expresses the required Java capability instead of trusting whatever javac happens to be on PATH.

mvn -v
mvn org.apache.maven.plugins:maven-toolchains-plugin:3.2.0:display-discovered-jdk-toolchains
./gradlew -version
./gradlew javaToolchains
./gradlew tasks --group build
Important distinction: a Gradle/Maven toolchain selects tools used by project tasks/plugins; it does not automatically change the compatibility target of every class file. Use a release/compatibility setting when you need a specific deployment baseline.

7. Packaging is a boundary, not a synonym for compilation

A JAR is a ZIP-based archive containing class files, resources, a manifest, and possibly additional metadata. A normal application/library JAR does not automatically contain every external dependency. A WAR has a different deployment model and layout. “The compiler succeeded” therefore says nothing about whether required resources were copied, whether runtime libraries will be available, or whether the package metadata is correct.

jar tf target/*.jar        # Maven output
jar tf build/libs/*.jar     # Gradle output
unzip -p target/*.jar META-INF/MANIFEST.MF 2>/dev/null || true

Artifact inspection is a safe read-only step. It answers “what bytes entered this archive?” before you deploy or publish it.

8. Inspect before changing the model

Use read-only evidence to establish the current contract. Record the build-tool runtime JDK, project model, source-set/task model, and output tree. Only then change layout, toolchain, or release settings.

./mvnw -v
./mvnw help:effective-pom
./mvnw dependency:tree
find target -maxdepth 3 -type f 2>/dev/null | sort
./gradlew -version
./gradlew projects
./gradlew tasks --all
./gradlew properties
find build -maxdepth 4 -type f 2>/dev/null | sort

9. Common misconceptions

  • “JDK 21 build means Java 21 artifact.” False when the compiler release target is lower.
  • “Tests passed, so test libraries can be in production.” Test-only dependencies and classes should remain outside the production package/runtime unless deliberately required.
  • “Changing the IDE project SDK changes CI.” Not necessarily. CI follows the repository build and agent/toolchain configuration.
  • “A JAR contains all dependencies.” A normal Java JAR usually does not. Fat/uber JARs and application distributions are separate packaging choices.
  • “Custom directories are more flexible, therefore better.” They can be valid, but every deviation from convention becomes configuration that tools, IDEs, newcomers, and CI must understand.

10. Mini-lab — predict outputs before compiling

On paper or in a disposable directory, place one production class, one production resource, one test class, and one test resource into the conventional tree. Before running a build, write down where you expect each item to appear after compilation/resource processing, and whether it should enter the production JAR. Then compare your prediction with the Maven or Gradle output tree. Do not change the layout until you can explain any mismatch.

Knowledge check

Maven runs on JDK 21 and the compiler uses --release 17. What Java level should the produced class files target?

A production class imports a helper located only in src/test/java. What should happen in a clean conventional build?

A class compiles successfully but throws NoClassDefFoundError at startup. Which classpath should you inspect first?

Why is jar tf useful before publication?

Does a toolchain declaration by itself prevent code from using APIs newer than your deployment runtime?

Summary

A JVM project contains separate production and test input/output paths, multiple classpaths, and multiple Java identities. The build-tool runtime JVM, selected project toolchain, compiler release target, test runtime, and deployment runtime must be named explicitly. Packaging is another transformation with its own contents and metadata. Inspect those boundaries before modifying configuration.

Next lesson

Build and inspect the complete flow

Lesson 2 creates matching Maven and Gradle lab projects, compiles/tests them, inspects class files and reports, verifies JAR contents, and proves a Java 17 target while the build runs on JDK 21.

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.