maven icon indicating copy to clipboard operation
maven copied to clipboard

Configurable Dependency Version Resolution Strategy

Open gnodet opened this issue 1 month ago • 26 comments

Summary

Currently, Maven's resolver treats direct dependencies (declared in a POM) as absolute version requirements that always override transitive dependencies, even when transitive deps require higher versions. This requires extensive use of <dependencyManagement> blocks to control resolution. This RFE proposes a configurable mechanism to allow more flexible version resolution while maintaining user control.

Motivation

  • Current Pain Point: Developers must explicitly manage many transitive dependencies through <dependencyManagement> to avoid version conflicts
  • Flexibility Need: Different projects have different requirements—some need strict version control, others prefer automatic upgrades to satisfy transitive requirements
  • Control Concern: Any change to default resolution behavior risks breaking builds unexpectedly (e.g., in reactor builds where in-development versions could be downgraded)

Proposed Solution

Introduce an optional directDependencyStrategy attribute at both project and dependency levels to control how direct dependencies participate in version conflict resolution:

<project directDependencyStrategy="flexible">
  <dependencies>
    <!-- Strategy "flexible" applies: allows transitive deps to override this version -->
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>lib-a</artifactId>
      <version>1.0</version>
    </dependency>
  </dependencies>
</project>

Or at the individual dependency level:

<dependency>
  <groupId>com.example</groupId>
  <artifactId>lib-b</artifactId>
  <version>2.0</version>
  <directDependencyStrategy>fixed</directDependencyStrategy>  <!-- This version always wins -->
</dependency>

Strategy Options

  • fixed (default): Direct dependency version is absolute; always wins version conflicts (current behavior)
  • flexible: Allow the configured version resolver to apply its normal conflict resolution rules (e.g., highest version, nearest) even for direct dependencies, permitting transitive dependencies to override this direct dependency

Configuration

Strategy default can be controlled at multiple levels (lowest precedence to highest):

  1. Maven Configuration: maven.resolver.directDependencyStrategy property

    <properties>
      <maven.resolver.directDependencyStrategy>flexible</maven.resolver.directDependencyStrategy>
    </properties>
    
  2. Project Level: <project directDependencyStrategy="..."> in POM

  3. Dependency Level: <directDependencyStrategy> within individual <dependency> block

Consumer Behavior

When a direct dependency with non-default strategy is consumed as a transitive dependency:

  • The directDependencyStrategy attribute should be removed/not applied
  • Resolution follows normal transitive dependency rules using the active conflict resolver
  • Consumer POM controls conflict resolution via their own direct dependency strategies

Safeguards

  • Build Validation: Maven warns if unexpected downgrades occur during resolution when using flexible strategy
  • Reactor Awareness: In-development versions in reactor builds always take precedence over deployed versions
  • Explicit Override: Individual dependencies can override project-level strategy setting

Benefits

  • Less Configuration: Developers need fewer <dependencyManagement> entries
  • Explicit Intent: Clear declaration of whether a version is fixed or flexible
  • Gradual Adoption: Backward compatible; defaults preserve current behavior
  • Flexibility: Works with any conflict resolver implementation (highest, nearest, etc.)
  • Platform Compatibility: Works alongside platform/BOM-based dependency management

Implementation Notes

  • Extends ConfigurableVersionSelector to check strategy configuration before applying fixed direct dependency preference
  • Requires POM model updates to support directDependencyStrategy attribute
  • Consumer POM transformations should strip directDependencyStrategy attributes during publishing
  • Strategy resolution follows hierarchy: dependency-level > project-level > Maven property > hardcoded default

gnodet avatar Nov 05 '25 12:11 gnodet

Having directDependencyStrategy at dependency level looks strange.

WDYT of something like:

  • dependencyVersionType: strict (select exactly this version), require (accept this or later), reject
  • dependencyVersionMode: strict (select exactly this version), require (accept this or later), reject
  • versionType: strict (select exactly this version), require (accept this or later), reject. It sounds better for <dependency> tag, however having a top level <project versionType="require"> would look misleading.
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>lib-a</artifactId>
      <version>1.0</version>
      <dependencyVersionType>require</dependencyVersionType>
    </dependency>
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>lib-a</artifactId>
      <version>1.0</version>
      <dependencyVersionMode>require</dependencyVersionMode>
    </dependency>
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>lib-a</artifactId>
      <version>1.0</version>
      <versionType>require</versionType>
    </dependency>

vlsi avatar Nov 05 '25 14:11 vlsi

Having directDependencyStrategy at dependency level looks strange.

WDYT of something like:

  • dependencyVersionType: strict (select exactly this version), require (accept this or later), reject

  • dependencyVersionMode: strict (select exactly this version), require (accept this or later), reject

  • versionType: strict (select exactly this version), require (accept this or later), reject. It sounds better for <dependency> tag, however having a top level <project versionType="require"> would look misleading.

    com.example lib-a 1.0 require com.example lib-a 1.0 require com.example lib-a 1.0 require

What would be the behaviour of the reject value ? Act like require, but fail if the version would change ?

I think we could have a top level dependencyVersionType on project, and a versionType on dependency.

gnodet avatar Nov 05 '25 15:11 gnodet

Consumer POM transformations should strip directDependencyStrategy attributes during publishing

Ah. I missed the idea is to strip the attribute on publishing.

In that regard, it makes no sense to have strict/reject/require version types.

If the new attribute was kept when publishing, then transitive dependencies could bring requirements like require 1.4 & reject [2.0,) below.


What would be the behaviour of the reject value ? Act like require, but fail if the version would change ?

My thought was as follows: sometime later the following cases might be helpful for both producers and consumers.

  • require 1.4 & reject [2.0,) In other words: "I require 1.4", however, if somebody requests 2.1, then the resolution should fail as I know there are incompatibilities.
  • strict 1.7 In other words, "this works with 1.7 only"

In that regard, versionType could control the way conflicts would be resolved. The initial implementation could be just strict + require (or fixed + flexible).

However, users would require a separate knob for "nearest vs highest" anyways, so their combinations would be as follows:

  • strict + nearest: current behavior
  • strict + highest: might be helpful for "forced override" of a version. I hope it should rarely be used
  • require + highest: solves NoClassDefFoundError caused by nearest. I hope this would become a new default one day
  • require + nearest: it is probably the same as strict + nearest. We could issue warning and be done with it

vlsi avatar Nov 05 '25 16:11 vlsi

Open question:

  • what about consumer pom, think we are still doomed there with this option since we do not want to flatten anymore nor exclude all transitive deps of flattend deps if we go back on flattening
  • what if 2 deps use require and have the same transitive deps (= we are in the same state than today, you do resolve it manually, it is just worse cause you have to do it whereas today it just works most of the time)

aren't we looking for something overkilled? Shouldn't we just do an extension which enables to refine dependencies based on a custom criteria like in gradle but not support it by default to encourage consistency and reliability?

rmannibucau avatar Nov 06 '25 08:11 rmannibucau

@gnodet , I have read the proposal again, and I do not see user story in it. In other words, I do not see what would really change for the end-user if they flip the added directDependencyStrategy setting. It would be great if you could clarify the end-to-end flow

vlsi avatar Nov 07 '25 07:11 vlsi

@gnodet , I have read the proposal again, and I do not see user story in it. In other words, I do not see what would really change for the end-user if they flip the added directDependencyStrategy setting. It would be great if you could clarify the end-to-end flow

Given this is a breaking change, the idea is to let the user opt-in by changing using the maven configuration property in .mvn/maven.properties or by setting the directDependencyStrategy="flexible" globally on the project element. The flag could also be changed in ~/.mvn/maven.properties or $MAVEN_HOME/conf/maven.properties to have a global effect for all projects. A possible (but optional) enhancement would be to make the flag available for each dependency as stated in the original proposal, but this would require much more change (first in the POM object model, then in the resolver), while the global flag should be implementable only in Maven.

Once the user switches to flexible, the direct dependencies would be subject to conflict resolution. They are currently excluded, per the rule that, if the user explicitly specifies a version for a given dependency, that one should be honored. So basically direct dependencies always win during conflict resolution.

So in your original mvn-mediation use case, we have lib-a depending on guice:5.0.1, lib-b depending on guice:3.0, example-application depending on lib-a, lib-b and guice:3.0. With the current behaviour, example-application has a direct dependency on guice:3.0, so that version wins. If the user switches the project to use flexible, the direct dependency suddenly becomes subject to dependency resolution. Changing directDependencyStrategy only would not really change the output of the resolution, since the default version conflict resolution favours the dependencies closer to the top of the tree. However, if the user also changes the conflict resolution to use highest, then, the direct dependency would be subject to higher policy conflict resolution and would be switched to guice:5.0.1.

So this has to be coupled with selecting the highest version, else, there's not much point.

Given the flag only affects direct dependencies, this means that when a library is consumed by another project, the dependencies of the libraries become transitive dependencies, and the flag no longer apply. It thus becomes completely useless, which is a good thing, as it allows Maven to remove it from the consumer POM, since consumer POMs have a 4.0.0 model, and the flag could not be included anyway.

As a first step, I would not include the per-dependency flag, I'm not really it's worth it at the moment. If we don't want to change the model, we could also only use the maven property, just like we have with the highest property for the dependency conflict resolver...

gnodet avatar Nov 07 '25 07:11 gnodet

Actually, I think this comes down to just a different VersionSelector, something like flexible-highest instead of highest. That may be sufficient.

gnodet avatar Nov 07 '25 08:11 gnodet

flexible-highest for a common use case which is a downgrade? bom-transitive maybe or unknown to be clear it will look random for end users maybe

rmannibucau avatar Nov 07 '25 08:11 rmannibucau

Given this is a breaking change, the idea is to let the user opt-in by changing using the maven configuration property in .mvn/maven.properties or by setting the directDependencyStrategy="flexible" globally on the project element

@gnodet , do you mean activation of the property would automatically switch the resolution from "nearest to highest"? Otherwise I do not see how directDependencyStrategy=flexible changes anything.

vlsi avatar Nov 07 '25 08:11 vlsi

If you think of a global property that changes the resolution strategy, then what do you think of the following?

  1. Global property in maven.properties to configure resolution mode (e.g. maven.dependency.resolution.mode): nearest (default to keep the current behavior), highest
  2. Dependency-level property like versionType = strict (default for nearest), require (default for highest)

In that case, there's no need in project-level dependencyVersionType.

vlsi avatar Nov 07 '25 08:11 vlsi

@vlsi I'm thinking that we just need a new strategy in the resolver. We recently introduced highest in the resolver, but that one does not take direct dependencies into account, following the unwritten rule that Maven does not override what the user explicitly stated.

If we relax this rule, assuming the user means "I require at least this version of the lib", we can add a new flexible (or whatever the name will be) strategy that will select the highest version for transitive and direct dependencies. Compared to my original proposal, it's much simpler, but does not allow to tune a given dependency explicitly.

The question is whether the added complexity (to configure for a given strategy) actually solves a real use case or not.

gnodet avatar Nov 14 '25 08:11 gnodet

but does not allow to tune a given dependency explicitly

How about the following:

  1. A new resolver goes with highest and treats default version declaration like require (even directly declared ones)

  2. We add a new tag to <dependency> so user could fine-tune if they really need to enforce a specific version (e.g. for testing or whatever purposes)

    <dependency>
      <groupId>com.example</groupId>
      <artifactId>lib-a</artifactId>
      <version>1.0</version>
      <versionType>strict</versionType> <!-- or "require" which could be a default for "highest" resolver -->
    </dependency>
    

Then all existing projects continue just like they are doing now: nearest resolver, directly declared dependencies win. A switch to highest resolver could be a single property in .mvn/...properties, and it could flip all deps (including direct) to highest mode. That sounds easy to understand and config to me.


The question is whether the added complexity (to configure for a given strategy) actually solves a real use case or not

It would solve the typical pain-points with NoClassDefFoundError which are quite common for Maven users. A single configuration switch like "use highest dependencies" would have better chances of producing workable classpath than the current "nearest".

vlsi avatar Nov 14 '25 08:11 vlsi

Yes, but this needs a model change, a change in the resolver API to add support for this new field, and the field to be taken into account the resolver strategies, either only highest, but maybe others, with unknown side effects.

Hence my proposal to just add a different strategy, or modify highest to just also affect direct dependencies. This would allow the switch with just a single property in ./mvn/...properties anyway.

It really comes down to whether switching specific dependencies is a real use case or not. Given the problem is not know when the project is created, and mainly appears later when upgrading dependencies, I'm not sure why a user would choose to not follow the strategy for a given set of dependencies. If the user did switch to the new or updated highest strategy, and want to force a given dependency version, I think he can always choose to use a closed range in order to enforce this version. Though if the project is a library to be consumed by other projects, this will bring the constraints down to the consumer.

gnodet avatar Nov 14 '25 08:11 gnodet

I would like to mention another scenario to keep in mind. I have not yet explored how the jar --hash-module option work. But from this example we can get (output of jar --describe-module):

exports com.me.util
requires java.base mandated
hashes com.me SHA-256 85c0539e4ca9a01b00f4c29a1a8b01cd452d1d97f437166b8bb415046dac65cb

Therefore, we may need to implement in the future a strategy that requires the SHA-256 hash of modular JAR files to match. I do not yet understand well in which direction those hashes work, I just wanted to mention that we may have a use case for this kind of scenario.

desruisseaux avatar Nov 14 '25 08:11 desruisseaux

It really comes down to whether switching specific dependencies is a real use case or not

In my experience, it is rarely used, however, it is useful. For instance, if a newer release brings a bug in one of the old methods, users might want to forcefully downgrade even if one of transitive deps specifies a newer version (e.g. they applied Dependabot PR and do not rely on newer APIs).

Here's such a case in Gradle documentation: https://docs.gradle.org/current/userguide/how_to_prevent_accidental_dependency_upgrades.html#why_prevent_accidental_dependency_upgrades

If the user did switch to the new or updated highest strategy, and want to force a given dependency version

I agree. For libraries, only ranges for strict make sense, and I believe for libraries, strict is not really needed in the first release.

For example, GitHub search shows only a few usages of strictly in all Apache build scripts: https://github.com/search?q=org%3Aapache+strictly+path%3Agradle&type=code

It might be, it would be good enough for the end users if the first release contains just a configuration option to flip between nearest vs highest (including direct ones).

implement in the future a strategy that requires the SHA-256 hash of modular JAR files to match

It relates to dependency verification scenario which is orthogonal to resolution strategy.

vlsi avatar Nov 14 '25 09:11 vlsi

It relates to dependency verification scenario which is orthogonal to resolution strategy.

We could create a strategy which, instead of saying "choose higher version", said "choose the version with matching SHA 256". I do not said that we need to do that, just mentioning as a possibility.

desruisseaux avatar Nov 14 '25 09:11 desruisseaux

Guys, can we step back please? I totally understand this is trivial to add a new strategy which can do whatever we do want but how do you converge in the consumer pom since we do not flatten without transitive deps (explicit dep list)? Is it ok to expose a tree which is not the one you did use to build and validate your project (not IMHO)?

rmannibucau avatar Nov 14 '25 10:11 rmannibucau

We could create a strategy which, instead of saying "choose higher version", said "choose the version with matching SHA 256". I do not said that we need to do that, just mentioning as a possibility.

I am afraid the selection based on hash is impractical for Java ecosystem at the moment:

  • Imagine there's https://repo1.maven.org/maven2/commons-io/commons-io/ How would you find which one corresponds to hash 12ccc61c573aed8a193f9727dc33795eba29d7c5? Maven repository layout is not designed to fetch artifacts by their hash.
  • How would you perform conflict resolution if different libraries require different hash values for commons-io?

vlsi avatar Nov 14 '25 10:11 vlsi

How would you find which one corresponds to hash 12ccc61c573aed8a193f9727dc33795eba29d7c5?

If the dependency tree contains two versions of commons-io (for example), the hash is computed for those two versions from the JAR files cached in the local .m2 repository.

Maven repository layout is not designed to fetch artifacts by their hash.

I know, but we don't need to search by hash. We only need to compute the hash of files in conflict after they have been downloaded.

How would you perform conflict resolution if different libraries require different hash values for commons-io?

The hash prevails if this is what the user requested. It would not be the default, only a strategy that users can select if this what they want.

desruisseaux avatar Nov 14 '25 10:11 desruisseaux

If the dependency tree contains two versions of commons-io (for example), the hash is computed for those two versions from the JAR files cached in the local .m2 repository

Got your point. That might work. However, can we please park the hash suggestion unless a compelling use case appears?

I do not think there are other dependency engines that implement such an approach, thus pioneering this in Maven would uncover many pitfalls.

vlsi avatar Nov 14 '25 10:11 vlsi

However, can we please park the hash suggestion unless a compelling use case appears?

Sure, no problem with that. I raised that point only for emphasising the value of a pluggable resolution strategy.

desruisseaux avatar Nov 14 '25 11:11 desruisseaux

@cstamas doesn't the jpms module hash thing overlaps with what you did about CI build harnessing so could converge?

rmannibucau avatar Nov 14 '25 11:11 rmannibucau

Guys, can we step back please? I totally understand this is trivial to add a new strategy which can do whatever we do want but how do you converge in the consumer pom since we do not flatten without transitive deps (explicit dep list)? Is it ok to expose a tree which is not the one you did use to build and validate your project (not IMHO)?

That's exactly what already happens when you consume a jar as a dependency. The resolver may (already) choose to bump to a more recent version. The only difference is that it would now be allowed with a direct dependency because a transitive Dep requires a higher version.

gnodet avatar Nov 14 '25 11:11 gnodet

That's exactly what already happens when you consume a jar as a dependency.

No, before the rule was unified in the build and mainly (even if tunable, nobody was using it almost) accross all projects, so all resolutions were well understood and known. Now the resolution of a 3rd party transitive deps will not more be aligned on the project which produces it view which is an issue IMHO so until we can flatten back the consumer pom we can have weird cases. Resolvable but useless headache for end users IMHO for an use case which is really rare since you never need that to resolve properly your dependencies (it is just an enhancement for comfort when you do not like defaults).

rmannibucau avatar Nov 14 '25 13:11 rmannibucau

That's exactly what already happens when you consume a jar as a dependency.

No, before the rule was unified in the build and mainly (even if tunable, nobody was using it almost) accross all projects, so all resolutions were well understood and known. Now the resolution of a 3rd party transitive deps will not more be aligned on the project which produces it view which is an issue IMHO so until we can flatten back the consumer pom we can have weird cases. Resolvable but useless headache for end users IMHO for an use case which is really rare since you never need that to resolve properly your dependencies (it is just an enhancement for comfort when you do not like defaults).

If we keep this as a property to be specified as a maven user property (cli or config file), this information won't be available to consumers, so this can only affect build time behavior.

gnodet avatar Dec 02 '25 09:12 gnodet

so this can only affect build time behavior.

it is my concern, your project view is different than your consumer view, not sure it is that sane since you can ask all your consumer to do work to be aligned with your expectations

rmannibucau avatar Dec 02 '25 10:12 rmannibucau

I've read the discussion on ML and this ticket. Another point I see, which might introduce a third option, are version ranges. For example strict could disallow them by default even without an explicit enforcer rule (of which many users are not aware of). Next to the fixed and flexible for the other use cases.

Related:

  • https://github.com/apache/maven-site/pull/1494 and the link mentioned there https://jlbp.dev/JLBP-14

Bukama avatar Dec 18 '25 15:12 Bukama