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.
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.
--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
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
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?
Java 17. The JDK running Maven and the compiler release target are separate identities.
A production class imports a helper located only in
src/test/java. What should happen in a clean
conventional build?
Main compilation should fail because test output is not part of the production compile classpath. That failure protects the packaging boundary.
A class compiles successfully but throws
NoClassDefFoundError at startup. Which classpath
should you inspect first?
The runtime classpath. Compilation evidence proves only that the class was available to the compiler, not that it will be available to the running JVM.
Why is jar tf useful before publication?
It verifies the actual archive contents without executing or publishing the artifact, so missing resources/classes can be caught at the package boundary.
Does a toolchain declaration by itself prevent code from using APIs newer than your deployment runtime?
Not necessarily. A toolchain selects a JDK; use an appropriate compiler release target as well when you need strict Java SE API/bytecode compatibility.
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.
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.