{News}
Jan 26, 2026
by alchemain team
Most Java teams have run into this situation at some point or another: you’re looking through a dependency scan, and there’s a CVE in a library you didn’t even know you were using.
Critical CVE in foo-json-1.2.3
Path: your-app → spring-boot-starter-web → some-framework → foo-json
You don’t import foo-json directly. It just happens to show up through a chain of other libraries. Still, it needs to be fixed, so you do the straightforward thing: override the version, rebuild, and call it a day.
Then later, it could be days, or sometimes even weeks, something odd shows up. A method missing deep inside a framework, or a behavior change that’s hard to reproduce. It could be a test that starts failing for reasons that don’t seem connected.
Nothing catastrophic, but enough to make you wonder whether the “safe” CVE fix pulled in a library version your frameworks weren’t expecting.
That’s the basic shape of the transitive dependency problem: you update one thing to stay secure, and something else breaks because the pieces weren’t all designed to move independently. The first part in our series on transitive dependencies looks at this issue in more detail, starting with understanding the differences between direct and transitive dependencies in the first place.
Direct vs. Transitive Dependencies: The Ones You See vs. the Ones You Run
When most of us think about our dependencies, we think about the ones we explicitly add to pom.xml or build.gradle. Spring Boot starters, database drivers, HTTP clients, and whatever else we use directly in our code.
But that’s only scratching the surface. The majority of what ends up on your classpath isn’t what you declared, it’s what those libraries pull in.
Think of transitive dependencies like the friends of friends who arrive at the party uninvited. Every framework brings along its own set of dependencies. And those dependencies bring their own, and so on. By the time everything is resolved, your application is running on a much larger graph than the one you wrote down. And some of these party attendees are not particularly house-trained.

A simplified example:
your-app
└─ org.springframework.boot:spring-boot-starter-web:3.2.0
├─ com.fasterxml.jackson.core:jackson-databind:2.17.0
│ └─ com.fasterxml.jackson.core:jackson-annotations:2.17.0
└─ org.springframework:spring-web:6.1.0
└─ org.springframework:spring-core:6.1.0
If your security scanner reports a CVE in jackson-databind, the obvious fix is to set a higher version in your application’s build file. Maven/Gradle will pick your override, the build will still compile, and on the surface everything looks fine.
What’s easy to forget is that frameworks like Spring were compiled and tested against the versions they depend on. When you override a transitive dependency, you’re effectively changing the ground under those frameworks, often in ways you don’t see until runtime.
That’s how a small “just bump the version” change ends up causing missing methods, changed defaults, or unexpected behavior later on. And this is the heart of the transitive dependency trap: you’re updating a library your code doesn’t call directly, but your frameworks very much do.
How Dependency Resolution Actually Works (and Why Overrides Cause Inconsistencies)
Understanding the impact of transitive dependency changes requires looking at how Maven and Gradle resolve version conflicts. Both tools flatten the dependency graph into a single classpath, but the rules they use are simple and largely unaware of API compatibility.
In Maven, the rule is nearest definition wins. If two versions of the same artifact appear in the graph, Maven picks the one that occurs closest to your module in the dependency tree. This means version selection depends on tree structure, not compatibility. Adding or removing an unrelated dependency can change which version wins purely because the “distance” to that dependency shifted.
In Gradle, the default is highest version wins. Gradle walks the graph, finds all candidates for a given coordinate, and selects the numerically highest version. This avoids accidental downgrades but introduces a different issue: a single override or a dependency introduced by another library can pull the entire graph up to a version that upstream frameworks weren’t built against. Gradle doesn’t check whether the highest version is binary-compatible with the ones the framework originally depended on.
Both tools ultimately produce a single, unified classpath. The JVM doesn’t know or care how the version was chosen; it simply loads the classes it finds. If the resolved version removes a method, changes a constructor signature, or relocates a class, nothing in Maven or Gradle will detect that. The compiler won’t catch it either if your own code doesn’t use that API. The breakage only appears at runtime when framework code, compiled against a different version, attempts to link symbols that no longer exist.
The important point is that dependency resolution is purely a metadata-level operation. It does not incorporate bytecode analysis, method-level compatibility checks, or awareness of how upstream libraries interact. As a result, version overrides can easily produce classpaths that are formally valid from the build tool’s perspective but incompatible from the JVM’s perspective.
In our next article in this series, we’ll take a closer look at the main reasons upgrading a vulnerable transitive dependency can cause problems for your tech stack. In the meantime, try 00felix for yourself to get a sneak peek at the solution.

