Security
Nov 18, 2025

The Transitive Dependency Dilemma: Choices to Make When Projects Evolve at Different Speeds

Why you should think about stability as well as security when CVE's show up in transitive dependencies

Give me the TL;DR
The Transitive Dependency Dilemma: Choices to Make When Projects Evolve at Different Speeds
For Qualys admins, NES for .NET directly resolves the EOL/Obsolete Software:   Microsoft .NET Version 6 Detected vulnerability, ensuring your systems remain secure and compliant. Fill out the form to get pricing details and learn more.

Suppose you're working on an application and your security scanner flags a CVE in a transitive dependency, which is a dependency you don’t use directly but is pulled in by something else you do use. Should you manually bump that insecure version? You could silence the alert by "fixing" the CVE, but is that the right move? The answer isn't as straightforward as it seems…

Engineers rarely think about dependency management. Tools like Maven or Gradle handle classpath complexities behind the scenes, while frameworks like Spring Boot ensure everything works together seamlessly. This innovation frees up developers to tackle bigger challenges.

But Maven dependency resolution should be understood so engineers have a better understanding of the implications of a version change. What ends up in your classpath isn’t always as straightforward as what is in the build files. Let’s explore.

In this example there are 3 applications. All of them are simple Java applications with no dependencies (other than depending on one another).

hello-world-lib

Contains a single HelloWorldLib class that has a hello() and goodbye() method. (Note that the typo is intentional - we will get to that soon.)

public class HelloWorldLib {


   public String hello() {
       String helloMessage = "eHllo! Welcome!";
       System.out.println("HelloWorldLib:helloWorld " + helloMessage);
       return helloMessage;
   }


   public String goodbye() {
       String goodbyeMessage = "Goodbye world!";
       System.out.println("HelloWorldLib:goodbyeWorld " + goodbyeMessage);
       return goodbyeMessage;
   }


}

framework

This application calls hello-world-lib through methods FrameworkService:sayHello() and FrameworkService:sayGoodbye(). It is intended to be a wrapper over the hello-world-lib library that adds in a bit nicer hello message.

public class FrameworkService {


   private final HelloWorldLib helloWorldLib;


   public FrameworkService() {
       helloWorldLib = new HelloWorldLib();
   }


   public String sayHello() {
       String helloMessage = helloWorldLib.hello();
       String niceHelloMessage = helloMessage.concat(" We are glad you are here!");
       System.out.println("FrameworkService:sayHello " + niceHelloMessage);
       return niceHelloMessage;
   }




   public String sayGoodbye() {
       String goodbyeMessage = helloWorldLib.goodbye();
       System.out.println("FrameworkService:sayGoodbye " + goodbyeMessage);
       return goodbyeMessage;
   }


}

platform

This application calls framework through the class PlatformService. It has a main method and can be run standalone.

public class PlatformMain {


   public static void main(String[] args) {
       PlatformService platformService = new PlatformService();
       platformService.sayHello();
   }
}


class PlatformService {
   private final FrameworkService frameworkService;


   public PlatformService() {
       frameworkService = new FrameworkService();
   }


   public String sayHello() {
       String helloMessage = frameworkService.sayHello();
       System.out.println("PlatformService:sayHello " + helloMessage);
       return helloMessage;
   }


  public String sayGoodbye() {
       String goodbyeMessage = frameworkService.sayGoodbye();
       System.out.println("PlatformService:sayGoodbye " + goodbyeMessage);
       return goodbyeMessage;
   }
}

Let’s focus on the “hello” chain first. The call chain is:

PlatformService:main 
 └── PlatformService:sayHello()
	└── FrameworkService:sayHello()
		└── HelloWorldLib:hello()

Running mvn dependency:tree will output the dependency graph:

[INFO] com.herodevs.dependency-management:platform:jar:1.0.0
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:1.0.0:compile

When we run the application, we can see the call chain in action from basic print statements:

HelloWorldLib:helloWorld eHllo world!
FrameworkService:sayHello eHllo world! We are glad you are here!
PlatformService:sayHello eHllo world! We are glad you are here!

Bug Fix: The Simple Case

Let’s assume we are in production, but we notice a bug in the hello-world-lib application.

   public String hello() {
       String helloMessage = "eHllo! Welcome!";
...

We will make the simple change to fix the typo:

   public String hello() {
       String helloMessage = "Hello! Welcome!";
...

But we need to update the version:

<groupId>com.herodevs.dependency-management</groupId>
<artifactId>hello-world-lib</artifactId>
<version>1.0.1</version>

We then push to production by running mvn clean install and put this new version in our Maven repository.

Now that we've fixed the bug in the library, how can we adopt that change in our platform application? The most straightforward approach is to re-release framework with a new version that pulls in the new hello-world-lib and then release a new platform application that pulls in framework. This keeps our same clean dependency graph in place, just with updates to incorporate the bug fix.

But what if the real world isn’t that simple? What if we own the platform application and we don’t want to wait for a framework release before we fix our own application? Even worse, what if framework is end-of-life and will never get an update? How can we update only platform and pull in the change from hello-world-lib? This is where Maven’s dependency overrides come in. 

Dependency Overrides

The <dependencies> section for `platform` starts with a single dependency on framework.

<dependencies>
   <dependency>
      <groupId>com.herodevs.dependency-management</groupId>
      <artifactId>framework</artifactId>
      <version>1.0.0</version>
   </dependency>
</dependencies>

Since it is pulled in through framework, the hello-world-lib is a transitive dependency of platform. Instead of pulling in hello-world-lib through framework, we have the option to specify it in platform pom.xml with the version we want. Through Maven’s Dependency Mechanism, we “can guarantee a version by declaring it explicitly in your project’s POM”.

<dependencies>
   <dependency>
      <groupId>com.herodevs.dependency-management</groupId>
      <artifactId>framework</artifactId>
      <version>1.0.0</version>
   </dependency>
   <dependency>
      <groupId>com.herodevs.dependency-management</groupId>
      <artifactId>hello-world-lib</artifactId>
      <version>1.0.1</version>
   </dependency>
</dependencies>

Specifying hello-world-lib in the <dependencies> section explicitly will change the version of hello-world-lib that is being used by framework.

[INFO] com.herodevs.dependency-management:platform:jar:1.0.1
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:1.0.1:compile

When we run PlatformMain, the bug fix from hello-world-lib:1.0.1 will get pulled in.

HelloWorldLib:helloWorld Hello world!
FrameworkService:sayHello Hello world! We are glad you are here!
PlatformService:sayHello Hello world! We are glad you are here!

This has powerful implications! We didn't change any Java code in platform or anything in framework. But we’ve altered the platform application behavior simply by pulling in a new dependency. This complex dependency resolution is now working as designed.

API Changes: The Real Risk

Let’s consider what happens when hello-world-lib adds more functionality than simply fixing a bug. Let’s assume that hello-world-lib makes an API change which changes the method signature of hello(). According to semver, this is a breaking change that should be put into a major version.

First, let’s make the code change to hello-world-lib:HelloWorldLib. Let’s add a name parameter to make the greeting more custom.

public String hello(String name) {
   String helloMessage = "Hello, " + name + "! Welcome!";
   System.out.println("HelloWorldLib:helloWorld " + helloMessage);
   return helloMessage;
}

Since this is a breaking API change, we need to release it in a new major version.

<groupId>com.herodevs.dependency-management</groupId>
<artifactId>hello-world-lib</artifactId>
<version>2.0.0</version>

We will “release to production” here with a mvn clean install to put this new library in the repository.

If we maintain platform we may notice a new 2.0.0 release of hello-world-lib is available. We know that framework won’t make the change to get this latest version, and we’ve already upgraded to `1.0.1` manually, so we decide to jump to `2.0.0`.

The new dependency graph with mvn dependency:tree now looks like this:

[INFO] com.herodevs.dependency-management:platform:jar:2.0.0
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:2.0.0:compile

When we run mvn clean package on platform, everything looks fine and we get a clean compile. 

mcnees@mac platform % mvn clean package

[INFO] ------------< com.herodevs.dependency-management:platform >-------------
[INFO] Building framework 2.0.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------


.....

[INFO] Building jar: /Users/mcnees/code/dependency-example/platform/target/platform-2.0.0.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Not until we run the Main class in platform do we see an error:

java.lang.NoSuchMethodError: 'java.lang.String com.herodevs.lib.HelloWorldLib.hello()'
    at com.herodevs.framework.FrameworkService.sayHello (FrameworkService.java:14)
    at com.herodevs.platform.PlatformService.sayHello (PlatformMain.java:21)
    at com.herodevs.platform.PlatformMain.main (PlatformMain.java:9)

The problem occurs because the API of hello-world-lib changed. The version required by framework is not API compatible with the version that we are specifying in platform. The disconnect is evident in the methods:

platform:2.0.0

public String sayHello() {
   String helloMessage = frameworkService.sayHello();
...

framework:1.0.0

public String sayHello() {
   String helloMessage = helloWorldLib.hello();
...

hello-world-lib:2.0.0

public String hello(String name) {
   String helloMessage = "Hello, " + name + "! Welcome!";
...

The platform application is stuck at this point. We can no longer adopt the latest changes from hello-world-lib:2.0.0 without risking a runtime error.

The Hidden Problem

The maintainers of platform now face a difficult situation. As bad as an immediate problem in production is, perhaps worse is accidentally creating an unstable application that will manifest a seemingly random error later. Let’s examine how everything can seem fine at first, but cause problems later on. For this example we’ll use the same dependency graph when the error was thrown:

[INFO] com.herodevs.dependency-management:platform:jar:2.0.0
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:2.0.0:compile

The original code includes another method chain for goodbye whose API remained unchanged between the hello-world-lib:goodbye1.0.0 and the 2.0.0 version.

If our platform code used “goodbye” instead of “hello”, it would look like this:

public static void main(String[] args) {
   PlatformService platformService = new PlatformService();
   //platformService.sayHello();
   platformService.sayGoodbye();
}

All of the other code remains the same. Running this code we now see this result:

HelloWorldLib:goodbyeWorld Goodbye world!
FrameworkService:sayGoodbye Goodbye world!
PlatformService:sayGoodbye Goodbye world!

It works! We never invoked the method with the breaking API change, so we never encountered the runtime error. This creates a dangerous situation where platform appears to be working perfectly: we are using the latest hello-world-lib version and everything seems fine, but we're actually in a precarious state.

The trouble arises when platformService.sayHello() is invoked. This method will never work and is a problem laying dormant in the code. The first time this code is invoked a runtime error will occur. This could be immediately after a release, or, even worse, it could happen months later when the business logic to trigger the problematic code is invoked for the first time. Either way, debugging this will be difficult since the root cause lies in dependency resolution, not the actual Java code.

Three Choices: Security, Stability, or Both

Why does this matter in the real world? Developers actually encounter this scenario frequently. 

Imagine that instead of fixing the simple typo "eHllo! Welcome!"; this change is instead a CVE remediation. Of course platform needs to pull in the latest version of hello-world-lib to stay secure. No problem, because it’s a patch release in hello-world-lib and as long as hello-world-lib plays by semver rules the override is safe.

If hello-world-lib moves on to a major version but framework does not, that leaves platform owners in a tough spot. hello-world-lib may have even more CVE fixes that are only published to a new major version. Owners of platform could override hello-world-lib to a new major, but we see how that carries serious risks - both realized and unrealized.

Owners of the platform application have three choices:

  1. Upgrade to the latest version of hello-world-lib, even if it is a major change. Then hope the application works and that breaking API changes won’t surface. This keeps platform secure but at the risk of stability.
  2. Keep hello-world-lib on an older, unsupported version. This maintains stability of platform but leaves it exposed to security issues in an outdated library.
  3. Obtain a secure fork of hello-world-lib at the latest 1.0.x version. This can be done by internally maintaining and publishing a secure fork or by finding a vendor to do this work for you. This maintains the stability of platform while also eliminating security issues.

Real World Scenarios

In the Java ecosystem, Spring Boot is widely used and is first class at bringing in versions of many different libraries that all work together. When a version of Spring Boot goes out of support, the list of dependencies that were stitched together is fixed in time. If you are running EOL Spring Boot versions, you are likely exposed to CVEs in the managed or transitive dependencies.

When CVEs appear in transitive dependencies deep in your dependency tree, you are faced with the scenario in this post. Do you upgrade the transitive dependency in your application? If it is a patch update, you’re safe to make that upgrade with confidence. The issue becomes more difficult when there is no patch update available that will remediate the CVE. You now have the three choices above - will you risk stability, risk security, or opt for a third party vendor?

Summary

Dependency version updates are routine engineering tasks that often escape the scrutiny of code changes, but they can significantly alter runtime behavior.  This is actually Maven functioning as designed, but engineers must be aware of the implications!  This post aims to raise awareness of the risks and tradeoffs in dependency management. By understanding the implications of each approach, developers can make more informed decisions when updating dependencies. 

Table of Contents
Author
Bob McNees
Engineering Director
Open Source Insights Delivered Monthly