Most Java build problems I have debugged in teams were not about the code at all. They were about the runtime looking in the wrong place. You compile, everything seems fine, then you run and get a NoClassDefFoundError. Or your test works in the IDE but fails in CI. That is the moment you meet the classpath face to face. I treat CLASSPATH as a precise, explicit map: a list of locations where the compiler and the JVM are allowed to look for classes and resources. If the map is wrong, even perfect code fails.
You are about to see that classpath is not a mystical environment variable. It is a concrete search order with rules, and those rules affect imports, packaging, JARs, modules, and tooling. I will show the mental model I use, how the JVM actually resolves classes, how I set classpath for everyday tasks, and where modern tools in 2026 either help or hide the problem. By the end, you will be able to debug classpath issues quickly, set it intentionally, and know when not to touch it at all.
The mental model I use: a checklist of places, in order
I imagine classpath as a playlist. The JVM reads the playlist from left to right. The first match wins, and anything not on the list is invisible. That is all it is. You can make the playlist by setting an environment variable, by passing a command line flag, or by letting your build tool assemble it. The moment you accept that classpath is a simple ordered list, a lot of confusing behavior becomes predictable.
Here is the core flow I keep in my head:
1) Compile time: javac reads classpath to resolve types and packages. If a referenced type is not found, compilation fails.
2) Runtime: java reads classpath to load bytecode and resources. If a referenced type is not found, runtime errors follow.
3) Order matters: If two JARs contain the same class, the earlier one wins.
4) The root is not the package: If your class is org.company.Menu, the classpath should include the directory that contains org/, not org/ itself.
This ordering rule is why I tell teams to prefer explicit, tool generated classpaths. Manual edits invite duplicate versions and class conflicts. When it goes wrong, I search the classpath list first, not the code.
Packages, imports, and how classpath closes the loop
Packages help you keep code organized and avoid naming clashes. For example, both college.staff.cse.Employee and college.staff.ee.Employee can exist without conflict. Imports simply tell the compiler which package to use for an unqualified class name.
A minimal example:
// Java Program to Illustrate Usage of importing
// Classes from packages and sub-packages
// Here we are importing all classes from
// java.io (input-output package)
import java.io.*;
// Main class
class IOExample {
public static void main(String[] args) {
// Print statement
System.out.println("I/O classes are imported from java.io package");
}
}
That import does not tell the JVM where java/io lives. The JVM already knows because the JDK ships those classes in the runtime image. For your own code, the import only describes the package. The classpath supplies the actual location.
If you write:
import org.company.Menu;
class App {
public static void main(String[] args) {
Menu menu = new Menu();
System.out.println(menu);
}
}
Your classpath must point to the directory containing org/ or to a JAR that contains org/company/Menu.class. The import points to the package name. The classpath points to the root location. I have seen people set the classpath to …/org/company and then wonder why nothing loads. The JVM expects to append the package path on top of the classpath root.
The mechanics that matter: compile vs run, wildcards, and precedence
I rarely set CLASSPATH directly anymore, but I always understand the difference between javac -cp and java -cp.
At compile time:
javac -cp libs/catalog-core.jar:build/classes App.java
At runtime:
java -cp libs/catalog-core.jar:build/classes App
If you use Windows, replace : with ; in classpath lists. That is a common and painful mistake for cross platform scripts.
Two details cause most confusion:
- Wildcards: lib/* on the command line adds every JAR in that directory, but it does not include nested directories. It also does not include non JAR files.
- Precedence: The JVM resolves classes by the order in the classpath list. If lib/v1.jar and lib/v2.jar both contain com.acme.Feature, whichever appears first wins. That is why I prefer deterministic build outputs rather than ad hoc lists.
I also keep an eye on the current directory. If you run:
java -cp . App
the dot means include the current directory. It is convenient for small demos and very risky for production because a stray class file can shadow the intended one.
JARs, manifests, and how I ship code safely
JARs are just zip files with a manifest. They package classes, resources, and metadata. For larger systems, they are the safest unit to put on the classpath. I avoid placing raw class directories on the classpath in production because it invites mismatches between compiled outputs and checked in sources.
Here is a minimal build you can run from scratch:
mkdir -p out/classes
javac -d out/classes src/org/company/Menu.java src/App.java
jar --create --file out/app.jar -C out/classes .
Then run:
java -cp out/app.jar App
If you want your JAR to declare extra dependencies, you can add a Class-Path entry in the manifest. I only do this for small tools or internal utilities because it shifts dependency management into a static file that is easy to forget to update.
Example MANIFEST.MF:
Manifest-Version: 1.0
Main-Class: App
Class-Path: lib/catalog-core.jar lib/catalog-ui.jar
In that case you can run:
java -jar out/app.jar
The JVM will add the listed JARs to the classpath. This is handy, but I still prefer build tool managed classpaths for larger systems.
Traditional vs modern workflows (2026): choose the lowest risk path
If I am guiding a team in 2026, I do not ask them to export CLASSPATH by hand. I tell them to let the build tool compute it and then inspect it when debugging. Manual classpath work is for quick experiments and scripts, not for shared projects.
Here is the guidance I give, framed as a choice:
Modern approach
—
Use Maven or Gradle or Bazel tasks to generate or print classpath
Use mvn -q -Dexec.classpathScope=runtime or gradle printClasspath (custom task)
Run via mvn exec:java, Gradle run, or a container entrypoint
Export IDE classpath when debugging, or run the same task as CII have also seen teams use lightweight runners like JBang or modern IDE run configurations. That is fine, but I always ask them to document the classpath used in CI. Consistency matters more than the tool choice.
If you are on Java 9+ and using the module system, remember that classpath and module path are separate lists. If you put a modular JAR on the classpath, it behaves like an unnamed module. That can be fine, but it affects reflective access and encapsulation. When you hit module warnings, check whether a JAR is on the wrong path first.
Common mistakes I see (and how I fix them fast)
When classpath issues show up, I do not guess. I enumerate the list and check the exact paths. These are the top mistakes I see:
- Root is wrong: You added …/org/company to classpath instead of the folder containing org/. Fix the root.
- Duplicate versions: Two JARs contain the same class. Use jar tf to inspect and remove the older version or fix the order.
- Windows separator: You used : instead of ;. This breaks classpath silently on Windows.
- IDE vs CLI mismatch: The IDE runs with one classpath, CI uses another. Make the build tool the source of truth.
- Shaded or repackaged JARs: A shaded JAR can mask classes from another JAR. Always check for shadowing when the same package appears in multiple artifacts.
A quick diagnostic method I recommend:
1) Print the effective classpath (build tool or run configuration).
2) Search for the missing class in each JAR or output directory.
3) Verify the path order and remove duplicates.
4) Re run with a clean build directory.
For example, if you get java.lang.ClassNotFoundException: org.company.Menu, I would run:
jar tf libs/company-core.jar | grep "org/company/Menu.class"
If it is not there, your dependency is missing. If it is there, check order and root paths.
When to set CLASSPATH and when to avoid it
I treat CLASSPATH like a sharp tool. It is powerful, but you do not leave it lying around for every program to pick up.
Use CLASSPATH when:
- You are running a quick local demo without a build tool.
- You are debugging a JVM process and need to inject a classpath override.
- You are working in a constrained environment like a minimal container.
Avoid CLASSPATH when:
- You have a multi module project with a build system.
- You need deterministic builds across machines.
- You share code with a team or CI.
If you do set it, prefer a short, explicit value and document it near the project. I also clear it when done to avoid surprising other Java programs in the same shell session.
A reliable, explicit run command looks like this:
java -cp "build/classes:lib/*" com.acme.app.Main
If I need to ensure a specific dependency version, I put that JAR first. But I avoid relying on ordering as a long term solution. Fix the dependency graph instead.
Performance and resource concerns: what actually matters
Classpath issues can slow startup, but the impact is usually about scanning, not execution. If you add hundreds of JARs, the JVM class loader spends extra time checking each one. In my experience, a bloated classpath adds noticeable delay at startup, often in the range of 10 to 30 ms per large library group on typical developer machines, and more in cold containers.
Here is what I do to keep startup predictable:
- Keep runtime classpath smaller than compile classpath. You do not need test libraries at runtime.
- Prefer a single, curated runtime distribution over a raw pile of JARs.
- Avoid duplicate classes that trigger class loader lookups and shadowing.
If you need ultra fast startup, consider building a runtime image with jlink or using a tailored container image. That shifts the classpath work to build time and keeps runtime checks minimal. It is not necessary for every app, but it is a good fit for CLI tools, serverless functions, and edge deployments.
A practical recipe I trust for real projects
When I join a new project, I do the same steps:
1) Run the build tool standard task (gradle run or mvn test).
2) Ask it to print the classpath and save it to a file.
3) Verify that the runtime classpath contains only runtime dependencies.
4) Add a small script to reproduce the run locally.
Here is a minimal Gradle task I often add to make classpath visible:
// build.gradle
tasks.register("printRuntimeClasspath") {
doLast {
println(sourceSets.main.runtimeClasspath.asPath)
}
}
And a Maven example:
mvn -q -Dexec.classpathScope=runtime -DforceStdout exec:classpath
Once you can see the list, you can solve almost any classpath issue quickly. The key is visibility, not guessing.
A deeper look at class loading: why classpath is only half the story
Classpath is a list, but the JVM does not load every class at startup. It loads them on demand through a class loader. That matters because it explains why a classpath error might only appear minutes into a run or only in a specific code path. The JVM resolves a class when a class is first referenced, and the class loader follows its own delegation rules.
In a simple mental model, think of three layers of class loaders:
- The platform loader (loads standard modules and platform classes).
- The application loader (loads your classpath).
- Custom loaders (frameworks and app servers often add their own).
The application loader follows a parent first rule in typical setups: it asks its parent loaders before it tries the classpath. This is why you can never override standard Java classes with something on your classpath. The platform gets the first shot.
There are real consequences:
- A class might exist on your classpath but still never get used because a parent loader already found it.
- Two different class loaders can load the same class name, resulting in ClassCastException even though the class names match. They are not the same class if the loader is different.
- App servers, plugins, and instrumentation often introduce extra loader layers. Your classpath list might be correct, but the loader hierarchy can still cause surprises.
When I debug class loading issues in complex apps, I log the class loader for a problem class and check where it came from. That tells me if the classpath is wrong or if the loader hierarchy is the real culprit.
The root rule, revisited with a real directory tree
This seems basic, but it is the number one mistake I see, so I keep repeating it. The classpath root is the directory that contains the package root. Here is a simple tree:
project/
build/classes/
com/
acme/
app/
Main.class
If your class is com.acme.app.Main, then the classpath should include project/build/classes. The classpath should not include project/build/classes/com or project/build/classes/com/acme. That would create a mismatch between package path and classpath root. In other words, the JVM does this internally:
- It takes the class name com.acme.app.Main
- It converts it into a path com/acme/app/Main.class
- It tries to append that to each classpath entry
So if you set classpath to project/build/classes/com, the JVM looks for project/build/classes/com/com/acme/app/Main.class. That path does not exist, so the class is invisible.
This is why I always sanity check classpath entries by hand for one known class. If the path does not line up with the package, I fix the classpath entry rather than chase down imports.
CLASSPATH vs -cp vs –class-path: how I choose
There are three common ways to set classpath:
- CLASSPATH environment variable
- -cp or -classpath command line flag
- –class-path command line flag (a long form alias)
The precedence is straightforward. If you pass -cp or –class-path, it overrides the CLASSPATH environment variable for that command. I almost always prefer the command line approach because it is explicit and local to the command. Environment variables linger and surprise other runs.
I will occasionally set CLASSPATH for quick REPL sessions or one off experiments, but I avoid it for anything that involves colleagues or automation. In a team setting, invisible environment state is a debugging tax.
If you need to combine CLASSPATH with an explicit -cp, remember that -cp replaces CLASSPATH. It does not append to it. When I really need both, I build a single explicit classpath string and pass it on the command line.
Resources on the classpath: not just classes
Classpath is not just about classes. It is also a resource lookup path. When you call getResource or getResourceAsStream, the class loader uses the same classpath entries to locate files.
That gives you two practical rules:
- Resource paths are relative to the classpath root, not your project root.
- The order of classpath entries matters for resources too.
If you have two files named config/app.yaml in different JARs, the first one found wins. This can be a feature when you want to override defaults, but it can also cause subtle bugs if you do not control the order. I have seen a staging config override a production config because the classpath order was different between a local run and a container build.
A simple practice helps: if a resource is intended to be overridden, name it in a predictable, clearly separated path like config/overrides/app.yaml and load it explicitly. Do not rely on classpath order to choose a configuration file unless you are intentionally using it as a layering mechanism.
The module path and the classpath: two parallel worlds
If you are on Java 9+, you have the module system. It introduces the module path as a separate concept from the classpath. This confuses people because it feels like two classpaths, and in a sense, it is.
The practical rules I use:
- The module path is for modular JARs (with module-info.class).
- The classpath is for everything else (legacy JARs, loose classes).
- If a modular JAR is on the classpath, it becomes an unnamed module and you lose strong encapsulation.
This has real consequences:
- Reflective access warnings often appear when code depends on internal packages across modules. If the JAR is on the classpath, the module system has fewer guardrails and you might get different warnings than you expect.
- A JAR can behave differently depending on whether it is on the classpath or module path. That can make CI and local runs diverge if your build tool has different defaults.
If I see module warnings or reflective access errors, I first verify whether each dependency is on the correct path. Often the fix is as simple as moving a JAR to the module path or vice versa.
Fat JARs, shaded JARs, and why they change classpath rules
Many projects package a fat JAR (also called an uber JAR) that contains all dependencies in a single file. This changes the classpath problem space because your runtime classpath might be a single JAR file instead of a long list.
This approach has benefits:
- One artifact to deploy
- No dependency list to manage at runtime
- Simple docker or serverless packaging
But it also introduces risks:
- Class shadowing is hidden inside the fat JAR
- Debugging a missing class requires inspecting the fat JAR contents
- If two dependencies contain the same class, the build tool chooses one, often silently
I treat fat JARs like an optimization, not a default. For small services and CLI tools, they are great. For large applications, I want to keep the dependency graph visible. If a fat JAR is required for deployment, I keep the original dependency list for debugging and test against the non fat configuration locally.
Classpath and tests: why test scope matters
Tests fail for classpath reasons more often than teams admit. The typical mistake is mixing compile, test, and runtime classpaths. If you accidentally include test utilities on the runtime classpath, your app might pass tests and then crash in production when those classes are missing.
Here is my approach:
- Keep test classpath separate from runtime classpath.
- When debugging, explicitly print both lists and compare them.
- Avoid putting test dependencies on the main classpath as a shortcut.
If you are using Maven, remember that test dependencies are not part of the runtime classpath unless you explicitly include them. If you are using Gradle, source sets provide separation, but it is easy to blur that line with custom tasks. I add an explicit check that no test scoped dependencies appear on runtime classpath when shipping artifacts.
A pragmatic checklist for NoClassDefFoundError vs ClassNotFoundException
These two errors are related but not the same, and they indicate different phases of failure.
- ClassNotFoundException usually appears when code tries to load a class by name at runtime and the class loader cannot find it.
- NoClassDefFoundError often appears when the JVM has already loaded a class that depends on another class that is missing or could not be initialized.
When I see ClassNotFoundException, I focus on the classpath entries. When I see NoClassDefFoundError, I inspect both classpath and static initialization errors. A static initializer can fail and then the JVM treats the class as unusable, which can surface as NoClassDefFoundError.
I keep a tight loop:
1) Confirm whether the class exists on the classpath.
2) If it exists, check whether its dependencies are present.
3) If dependencies are present, check whether any static initialization throws an exception.
This saves time because you do not waste cycles assuming the class is missing when the real issue is a static initialization failure.
IDEs, run configurations, and invisible classpaths
IDEs make Java easier, but they also hide the classpath. When a test passes in the IDE and fails in CI, nine times out of ten it is because the classpath differs. IDEs often add extra entries for convenience, such as annotation processors or test libraries.
My rule: the build tool is the source of truth. If the IDE run configuration is different, I sync it to the build tool.
Concrete tips:
- In IntelliJ, inspect the run configuration classpath and compare it to the build tool output.
- In Eclipse, check the build path and confirm it matches the Maven or Gradle dependencies.
- Avoid manual library additions in the IDE; always add dependencies through the build file.
If I need to reproduce a CI failure locally, I run the exact command used in CI from the terminal, not from the IDE. That removes ambiguity.
Classpath in containers and serverless deployments
Containers and serverless environments often compress your classpath down to a minimal set of JARs or a single fat JAR. This is good for performance and predictability, but it can hide problems in development.
Common container pitfalls:
- A Docker build caches an older JAR, so the classpath points to a stale artifact.
- The working directory differs from local runs, so relative classpath entries fail.
- The container entrypoint uses java -jar while local runs use java -cp, resulting in different dependency resolution.
My practice is to make runtime classpath explicit in the container entrypoint. If I use java -jar, I confirm that the manifest Class-Path is accurate or that the JAR is truly self contained. If I use java -cp, I make sure all classpath entries exist inside the image.
A realistic scenario: conflicting dependencies in a multi module app
Here is a scenario I see a lot. A multi module app has two modules that depend on different versions of the same library. The build tool resolves it, but the classpath order determines which one wins. The app compiles, but a runtime method is missing because the older version was chosen.
Symptoms:
- Compilation passes
- Runtime throws NoSuchMethodError or NoSuchFieldError
- The class exists, but the method signature does not
Fix strategy:
1) Identify the class that is missing a method.
2) Locate which JAR is providing it by inspecting classpath order.
3) Adjust the dependency graph to force a single version.
4) Rebuild and verify the effective classpath.
I emphasize this because NoSuchMethodError is a classpath error, not a code error. The code you compiled against is not the code you ran with. That is a classpath integrity issue.
Troubleshooting commands I keep handy
When I need to debug fast, I use a small set of commands. They are simple, but they surface the classpath truth quickly.
- Print the classpath from a build tool task and save it to a file.
- Use jar tf to check if a class exists inside a JAR.
- Use grep to search for a class name across JARs when duplicates are suspected.
- Use java -verbose:class to see which classes are loaded and from where.
For example, this command shows class loading details:
java -verbose:class -cp lib/*:build/classes com.acme.app.Main
It prints where each class is loaded from, which is a quick way to detect shadowing or unexpected JARs. I only use it briefly because it produces a lot of output, but it is invaluable when you are stuck.
Edge cases that surprise people
These are the odd cases that are not obvious, but they come up in real projects:
- Spaces in paths: If a classpath entry has a space, wrap the whole classpath in quotes. Otherwise the shell splits it.
- Non ASCII paths: Sometimes classpath entries fail on systems that do not handle Unicode file names correctly. This is rare but real, especially on older build tools or misconfigured environments.
- Mixed separators: On Windows, it is easy to accidentally use both ; and : in a classpath, resulting in broken or partial classpaths.
- Case sensitivity: macOS and Windows are often case insensitive, Linux is case sensitive. A classpath issue might surface only in Linux because of case mismatches in file names.
- Nested JARs: If a JAR contains other JARs, they are not automatically added to the classpath unless a custom class loader or launcher is used. Spring Boot handles this with its own loader, but plain java does not.
I mention these because they can waste hours if you do not consider them. A path with spaces is a classic example: the classpath looks right, but the JVM receives a broken list because the shell split it.
Managing classpath visibility in large codebases
In large codebases, classpath is often assembled by a dependency management tool, and it can be hundreds of entries long. I do not read that list line by line. I search and filter it based on what I need.
My approach:
- Store the classpath list in a file and treat it like a build artifact.
- Search the list for specific JARs or package names.
- Compare classpath snapshots between working and failing environments.
This is especially useful in CI. If a job fails, I dump the classpath into the logs. Later, I can compare the classpath between a passing and failing run. Differences often reveal missing or conflicting dependencies quickly.
A second practical recipe: classpath sanity check script
When a project is big enough, I add a small script that validates the runtime classpath. It does not need to be fancy. The script can check that:
- The classpath contains required core dependencies.
- No forbidden test or debug libraries are present.
- No duplicate versions of critical libraries exist.
Even a simple grep based approach catches a surprising number of issues. The goal is to make classpath correctness visible early, not only after a runtime failure.
Comparing classpath, module path, and boot classpath in one table
I use this table in training sessions because it clarifies terms quickly:
Purpose
—
Load application and legacy classes
Load modular JARs with strong encapsulation
Override or extend core classes
In modern Java, you rarely touch the boot classpath. I mention it only because older articles or scripts might still reference it. For most teams, it is not part of everyday work.
The role of build tools: classpath as a dependency graph
Modern build tools do two critical things for classpath:
- They compute a dependency graph.
- They produce an ordered classpath based on resolution rules.
If you understand the graph, the classpath becomes predictable. That is why I always check the resolved dependency tree when I see conflicts. It shows which dependency pulled in a version and why.
The key is to let the build tool manage the graph and only override when you have a reason. Manual classpath manipulation bypasses the graph and usually creates more problems than it solves.
A note on AI assisted workflows in 2026
In 2026, more teams use AI assistants to generate build files, fix dependency conflicts, or propose upgrades. That can be helpful, but it introduces a risk: an assistant might resolve a conflict in a way that looks correct but subtly changes classpath ordering or version selection.
My advice is simple: if an AI suggests a build change, verify the effective classpath before and after. If the runtime classpath changed, understand why. I trust tools when they are transparent. I do not trust any change that alters the classpath without explanation.
Practical scenarios: when a classpath change is the right fix
It is easy to blame the classpath for every error, but sometimes a code fix is the right move. Here are scenarios where I do change the classpath:
- A new dependency is missing in runtime because it was only added to compile scope.
- A class exists in a different module than expected and the module is not on the runtime classpath.
- A resource file is missing because it was not packaged into the JAR.
And here are scenarios where I do not change the classpath:
- A NoClassDefFoundError caused by a static initializer failure. That is a code issue or configuration issue, not a classpath issue.
- A test failure that is due to a mock or fixture missing. That is a test setup issue.
- A runtime bug caused by a version change in a library. The fix is to align versions, not just reorder the classpath to hide the conflict.
I mention this to keep the diagnosis honest. A classpath fix can mask the real problem if the code is broken or the library version is wrong.
Debugging checklist: the one I actually use
When a classpath issue appears, I do the same thing every time:
1) Reproduce the issue with a clean build.
2) Print the effective runtime classpath.
3) Search for the missing class in the classpath entries.
4) If the class exists, check its dependencies and initialization.
5) If duplicates exist, remove them or align versions.
6) Re run with explicit classpath and minimal dependencies to isolate.
This checklist is boring, and that is why it works. It avoids guesswork and quickly narrows the scope.
A larger example: compiling and running a multi package app
Here is a fuller example that mirrors a real project layout. Suppose your source tree looks like this:
src/
com/
acme/
app/
Main.java
com/
acme/
util/
Texts.java
You can compile and run with a clean output directory like this:
mkdir -p out/classes
javac -d out/classes src/com/acme/util/Texts.java src/com/acme/app/Main.java
java -cp out/classes com.acme.app.Main
Notice again that the classpath points to out/classes, not to out/classes/com. The package path is always appended by the JVM. Keeping this example in your head makes classpath errors obvious.
When classpath is not enough: custom class loaders
Some frameworks and plugin systems load classes from locations that are not on the classpath. They use custom class loaders that read from specific directories, remote locations, or isolated plugin jars. In these cases, classpath is still relevant, but it is not the whole picture.
If you are working with plugin systems, OSGi, or application servers, you need to understand the loader hierarchy and which loader owns which classes. A common issue is that a plugin cannot see a class even though it is on the main classpath because the plugin loader is isolated. The fix is often to move the dependency into the plugin scope or to export it explicitly through the framework configuration.
I mention this because developers sometimes keep adding JARs to the classpath and wonder why the plugin still fails. The problem is not the classpath at all. It is the loader boundary.
The classpath as a contract
At the end of the day, I treat classpath as a contract between code and runtime. The code assumes certain classes and resources exist. The runtime promises to provide them if they are on the classpath. When the contract is broken, errors surface in unpredictable places.
The fix is almost always to make the contract explicit:
- Make the classpath visible and reproducible.
- Avoid hidden environment variables.
- Prefer build tool outputs over manual lists.
- Keep runtime classpath minimal and intentional.
Once the contract is explicit, classpath issues become routine to debug instead of mysterious failures.
Closing
Classpath is just a list of places the JVM is allowed to look. When that list is right, code runs; when it is wrong, nothing else matters. I recommend you treat it like a contract: explicit, ordered, and generated by a tool when the project grows beyond a tiny demo. Keep the root path rule in mind, and remember that imports only describe packages, not locations. If you ever hit a class loading error, do not hunt through your code first, inspect the effective classpath.
If you want a reliable routine, here is what I do every time: I use the build tool to print the runtime classpath, verify that the expected JARs are present, and remove duplicates or outdated versions. For quick experiments, I pass -cp directly and keep it short. For production, I avoid global CLASSPATH settings and prefer a reproducible build. That combination gives you fast feedback locally and stable behavior in CI.
Your next step can be simple: add a task that prints the classpath, run it, and keep the output in your project notes. The next time you see a class loading error, you will have the map ready, and the fix will be straightforward.


