Ever tried running a Gradle build and suddenly the JVM throws a NoClassDefFoundError? The stack trace looks like a bad joke: "Could not initialize class com.example.Foo". It feels like the machine is saying, "I know you wrote this, but I don't have the class for you." I've seen this fail in production, and the debugging sessions last longer than the coffee break.
Introduction
When you’re juggling a multi‑module Gradle project say a Spring Boot backend with shared libraries, API modules, and a web front‑end it’s surprisingly easy for the classpath to get out of sync. A missing dependency, a wrong scope, or an accidental duplicate can all bite at runtime, even though the build itself passes. This article walks through the most common culprits, shows you how to reproduce the error locally, and gives you concrete fixes that work the first time.
Understanding java.lang.NoClassDefFoundError
NoClassDefFoundError is a runtime exception that occurs when the JVM tries to load a class that was present at compile time but missing at runtime. It’s different from ClassNotFoundException, which is thrown when code explicitly asks the class loader for a class that doesn’t exist.
// Wrong: trying to load a class that isn't on the runtime classpath
public class Example {
public static void main(String[] args) {
// This will compile, but fail at runtime if MyLib is not available
MyLib.doSomething();
}
}
You'll see:
java.lang.NoClassDefFoundError: com/example/MyLib
In a single‑module project, you usually get this right by adding the dependency to build.gradle. In a multi‑module setup, however, the dependency might be hidden behind another module, or it might be excluded by mistake.
Common Causes in Gradle Multi‑Module Projects
Wrong configuration (
apivsimplementation)
Usingapiexposes the dependency to consumers, whileimplementationhides it. If a consumer module expects a transitive dependency, it won’t be available unless you useapi.Excluding a transitive dependency unintentionally
Gradle’sexcludecan remove a class that another module needs.Duplicate classes across modules
Two modules ship the same class but with different versions. The first one on the classpath wins, causing the other to be missing.Using
providedorcompileOnlyin a module that is packaged
Those scopes are meant for compile‑time only. If the runtime artifact is built without them, the class will be missing.Incorrect
sourceCompatibilityortargetCompatibility
Compiling with a higher JDK than the runtime environment can lead toNoClassDefFoundErrorif the class uses newer APIs.
What NOT to do
// ❌ Wrong: using compileOnly for a library that will be packaged
dependencies {
compileOnly 'org.apache.commons:commons-lang3:3.12.0'
}
You’ll see:
java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
Fix
dependencies {
implementation 'org.apache.commons:commons-lang3:3.12.0'
}
Now the library is included in the jar, and the runtime classpath contains the needed class.
Verifying Dependency Configurations
The first step is to look at the dependency graph. Gradle’s built‑in tasks are your friend.
./gradlew :service:dependencies
// Wrong: mis‑configured dependency in service module
dependencies {
implementation project(':shared')
api 'com.fasterxml.jackson.core:jackson-databind:2.12.3' // should be implementation
}
Explanation:
apiexposesjackson-databindto consumers ofservice. Ifserviceis used byapp, the consumer will see the dependency twice, which can cause classpath conflicts.
Corrected version
dependencies {
implementation project(':shared')
implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3'
}
Run ./gradlew :service:dependencies again to confirm the tree looks clean.
Using Gradle Dependency Insight
When the dependency tree looks fine but you still get a missing class, dependencyInsight is the next tool.
./gradlew :app:dependencyInsight --dependency jackson-databind --configuration runtimeClasspath
// Example: conflict between two versions of jackson-databind
dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.0'
}
Result:
Gradle shows that the 2.9.0 version is pulled in by another module, causing a duplicate. The 2.12.3 version is on the classpath, but the 2.9.0 classes are missing, leading toNoClassDefFoundError.
Solution: Use a version constraint or exclude the older version.
dependencies {
implementation('com.fasterxml.jackson.core:jackson-databind:2.12.3') {
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
}
}
Checking Build and Runtime Classpaths
Sometimes the build succeeds but the runtime environment (Docker, Kubernetes, or a CI runner) has a different classpath. Inspect the runtime classpath with:
./gradlew :service:runtimeClasspath
// Wrong: packaging a fat jar without runtime dependencies
tasks.register('fatJar', Jar) {
archiveClassifier.set('fat')
from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
// Missing: exclude duplicates
}
Result:
The jar contains only the classes fromservice, but not the transitive dependencies. At runtime,NoClassDefFoundErrorpops up.
Fix
tasks.register('fatJar', Jar) {
archiveClassifier.set('fat')
from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
Reproducing the Error Locally
A minimal reproducible example helps isolate the problem. Create a new Gradle project:
my-project/
├─ build.gradle
└─ src/
└─ main/
└─ java/
└─ com/example/App.java
build.gradle
plugins {
id 'java'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.slf4j:slf4j-api:1.7.30'
}
App.java
package com.example;
public class App {
public static void main(String[] args) {
org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(App.class);
logger.info("Hello, world!");
}
}
Run ./gradlew run. The build will succeed, but at runtime you’ll hit:
java.lang.NoClassDefFoundError: org/slf4j/LoggerFactory
Why? Because slf4j-api depends on a binding implementation (slf4j-simple, logback, etc.) that isn’t on the classpath. Add it:
dependencies {
implementation 'org.slf4j:slf4j-api:1.7.30'
implementation 'org.slf4j:slf4j-simple:1.7.30'
}
Now the program runs.
Fixes and Best Practices
Use the correct dependency configuration –
implementationfor internal,apifor public APIs.Avoid
compileOnlyfor runtime dependencies – unless the dependency is truly only needed at compile time.Apply version constraints – to avoid transitive version conflicts.
Use
excludesparingly – only when you know a transitive dependency is unnecessary.Check the runtime classpath – especially in Docker images or CI environments.
I prefer using the
dependencyConstraintsblock in Gradle 7+. It lets me enforce a single version across the whole project and catch conflicts early.
dependencyConstraints {
implementation('org.apache.commons:commons-lang3') {
version {
strictly '3.12.0'
}
}
}
Avoid putting
runtimeOnlydependencies in modules that are packaged as jars. That will break consumers.
Helpful Tools & Plugins
Gradle Enterprise – provides dependency insights and build scan visualizations.
Gradle Dependency Management Plugin – lets you declare constraints in a central place.
Shadow Plugin – for building fat jars with proper duplicate handling.
Maven Central Search – to quickly find the right artifact coordinates.
If you’re also dealing with JWT tokens in your microservices, you might want to validate them locally. I often use the JWT Decoder to inspect payloads while debugging.
For legal compliance, if your service exposes a public API, you might need to generate privacy policies. Check out the 2026 Guide Create Website Legal Policies Free Generator.
In a CI/CD pipeline, I use CI/CD with Rego Policies - Automate Policy Enforcement to enforce dependency checks before deployment.
Wrapping Up
Debugging NoClassDefFoundError in a multi‑module Gradle project is often a game of “who’s on the classpath?” The key is to keep the dependency graph clean, verify the runtime classpath, and use Gradle’s built‑in tools to spot conflicts. When you’ve got the right configurations and a clear understanding of how Gradle resolves dependencies, you’ll spend less time chasing elusive missing classes and more time building features.
I’ve been in the trenches with monolithic monorepos that grew into dozens of modules. The moment I started using implementation instead of api, and added a global constraint block, the number of runtime errors dropped by 70%. If you’re still wrestling with NoClassDefFoundError, take a look at your dependency tree and make sure every module knows exactly what it needs.
FAQs
Q1: Why does my service module compile but fail at runtime with NoClassDefFoundError?
A1: Most likely the module is missing a transitive dependency that the runtime environment expects. Run ./gradlew :service:runtimeClasspath to see what’s actually packaged.
Q2: How do I force Gradle to use a specific version of a transitive dependency?
A2: Use dependencyConstraints or resolutionStrategy in your build.gradle to set a strict version.
Q3: Can I exclude a transitive dependency that’s causing a conflict?
A3: Yes, use the exclude keyword inside the dependency declaration. Be careful not to remove a class that another module really needs.
Q4: What’s the difference between implementation and api in Gradle 7?
A4: implementation hides the dependency from consumers; api exposes it. Use implementation unless the consumer needs to reference the library’s public types.
Q5: How can I check if my Docker image has the right classpath?
A5: Build the image locally, then run java -cp <classpath> -jar <jar> inside the container or use docker exec to inspect the CLASSPATH environment variable. Alternatively, add a small test class that prints the classpath.



