Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save gnodet/449ed46648fbf2a7ba63920e2d9b6244 to your computer and use it in GitHub Desktop.

Select an option

Save gnodet/449ed46648fbf2a7ba63920e2d9b6244 to your computer and use it in GitHub Desktop.

Maven 3 vs Maven 4: Plugin Dependency Resolution

TL;DR

Maven 3 gives a mojo one dependency resolution — a single Set<Artifact> for one scope, stuffed into a mutable MavenProject. A mojo that needs multiple scopes must request the broadest (TEST) and filter, but filtering a combined graph does not reproduce independent resolution — test-scoped roots alter conflict mediation for non-test artifacts, silently producing wrong versions. The maven-dependency-plugin is the worst offender: nearly every goal resolves at TEST scope and exposes includeScope/excludeScope as if they're equivalent to independent resolution. They're not.

Maven 4's @Resolution lets each field independently declare its pathScope and desired result type. Each field triggers its own resolution pass with its own conflict resolution, injecting immutable results. The runtime classpath is the real runtime classpath — not a filtered view of the test classpath.


The Problem with Maven 3

In Maven 3, dependency resolution for plugins is controlled by a class-level annotation on the Mojo:

@Mojo(name = "my-goal", requiresDependencyResolution = ResolutionScope.COMPILE)
public class MyMojo extends AbstractMojo {

    @Parameter(defaultValue = "${project}", readonly = true)
    private MavenProject project;

    public void execute() {
        // This set was populated ONCE, before execute() was called
        Set<Artifact> artifacts = project.getArtifacts();
    }
}

The key problems:

1. Resolution is computed once and cached

Maven 3's LifecycleDependencyResolver resolves dependencies before the mojo executes and caches the result in a ProjectArtifactsCache. The cache key is based on the project, resolution scope, and repository session. Once computed, subsequent mojos in the same lifecycle execution that request the same scope get the cached result — they don't re-resolve.

2. The result is mutably stuffed into MavenProject

The resolved artifacts are set via project.setResolvedArtifacts(...), which mutates the MavenProject object. This means:

  • All mojos share the same mutable state
  • A mojo that runs later can see artifacts that were resolved for a different scope by an earlier mojo (because getArtifacts() returns whatever was last set)
  • There's no way for a mojo to say "I want compile-scoped deps" and another to say "I want test-scoped deps" independently — the project holds one set at a time

3. The cache is scope-aware, but the project is not

The ProjectArtifactsCache key includes scopesToCollect and scopesToResolve, so RUNTIME and TEST produce different cache keys — each is resolved independently and cached correctly. But after every cache lookup (hit or miss), LifecycleDependencyResolver overwrites the single mutable slot:

1. Mojo A runs (RUNTIME scope)
   → cache MISS → resolves runtime deps
   → project.setResolvedArtifacts(runtimeArtifacts)  ← PROJECT STATE

2. Mojo B runs (TEST scope)
   → cache MISS → resolves test deps
   → project.setResolvedArtifacts(testArtifacts)  ← OVERWRITES

3. Mojo C runs (RUNTIME scope)
   → cache HIT → gets runtime deps from cache
   → project.setResolvedArtifacts(runtimeArtifacts)  ← OVERWRITES AGAIN

Each mojo sees its own scope's result in project.getArtifacts() because setResolvedArtifacts runs right before execute(). But forked executions (@Execute) can corrupt this — the forked mojo's resolution overwrites the project state, and the outer mojo sees the wrong set when control returns.

4. A mojo cannot get two scopes independently

The scopes are hierarchical:

COMPILE              → compile, provided, system
RUNTIME              → compile, runtime
COMPILE_PLUS_RUNTIME → compile, provided, system, runtime
TEST                 → compile, provided, system, runtime, test  ← everything

A mojo can only declare one requiresDependencyResolution. If it needs both runtime and test dependencies, the only option is to request TEST (the superset) and filter by artifact.getScope(). But this is fundamentally wrong — you cannot recover the runtime dependency graph by filtering the test graph.

5. Filtering the test graph does not give you the runtime graph

Dependency conflict resolution (nearest-wins, scope mediation) is path-dependent. Adding or removing root nodes changes which version wins globally. The dependency graph is the output of a constraint solver — you can't recover one solution by filtering another.

Consider this example:

Runtime resolution (starting from compile + runtime deps only):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

my-app
├── lib-a:1.0 (compile)
│   └── commons-lang3:3.12 (compile)    ← WINS (depth 2)
└── lib-b:1.0 (runtime)
    └── lib-c:1.0
        └── commons-lang3:3.14 (compile)  ← LOSES (depth 3)

→ Runtime classpath gets commons-lang3:3.12 ✓


Test resolution (starting from compile + runtime + test deps):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

my-app
├── lib-a:1.0 (compile)
│   └── commons-lang3:3.12 (compile)    ← depth 2
├── lib-b:1.0 (runtime)
│   └── lib-c:1.0
│       └── commons-lang3:3.14 (compile)
└── test-framework:1.0 (test)
    └── commons-lang3:3.14 (compile)    ← also depth 2!

→ Conflict resolution between 3.12 and 3.14 at same depth
  — winner depends on POM declaration order
→ Filtering out test-scoped artifacts does NOT undo the
  version mediation that test-framework caused

A test-scoped dependency can pull in transitive compile-scoped deps that change which version wins at any depth. The mere presence of test roots alters the conflict resolution for artifacts that are not themselves test-scoped:

my-app
├── lib-a:1.0 (compile)
│   └── guava:31.0              ← depth 2
└── test-helper:1.0 (test)
    └── guava:33.0              ← also depth 2, declared second

→ In the TEST graph, guava resolves to 31.0 or 33.0 (POM-order dependent)
→ In a true RUNTIME-only graph, guava is unambiguously 31.0
→ Filtering test-scoped artifacts from the TEST graph gives you the wrong
  guava version — guava itself is compile-scoped, so it passes the filter

There is no workaround within the Maven 3 plugin API. A mojo gets one resolution, one graph, one set of winners. If it needs multiple scopes, it's stuck with a filtered view of the broadest scope — which can silently produce wrong versions.

6. Reactor builds can see stale or missing artifacts

When @requiresDependencyResolution triggers in an early lifecycle phase (e.g., generate-sources), sibling reactor modules may not have been built yet. The resolution is attempted once, and if it fails or returns incomplete results, the mojo is stuck with that.

7. All-or-nothing: collection vs. resolution

Maven 3 added @requiresDependencyCollection as a lighter-weight alternative — it gathers the transitive dependency coordinates without downloading the files. But both annotations are class-level, and a mojo can only declare one resolution scope.


Which Maven 3 Plugins Are Affected?

maven-compiler-plugin (3.13.0) — Safe

Each goal is a separate mojo with its own scope, and neither filters:

Goal Scope Filters?
compile ResolutionScope.COMPILE No — iterates project.getArtifacts() as-is
testCompile ResolutionScope.TEST No — iterates project.getArtifacts() as-is

The classpath is built by walking project.getArtifacts() without any scope check:

// CompilerMojo.getCompileClasspathElements()
for (Artifact a : project.getArtifacts()) {
    if (a.getArtifactHandler().isAddedToClasspath()) {
        list.add(a.getFile());  // no scope check
    }
}

Safe because each goal requests exactly the scope it needs and doesn't try to simulate a narrower scope from a broader resolution.

Note: The current master branch has already been ported to the Maven 4 API (PathScope.MAIN_COMPILE / PathScope.TEST_COMPILE + DependencyResolver).

maven-surefire-plugin — Configurable Footgun

Surefire declares ResolutionScope.TEST and builds the test classpath from project.getArtifacts():

@Mojo(name = "test", requiresDependencyResolution = ResolutionScope.TEST)
public class SurefireMojo extends AbstractSurefireMojo { ... }

The filtering is user-configurable via classpathDependencyScopeExclude:

private TestClassPath generateTestClasspath(ArtifactFilter... dependencyFilters) {
    Set<Artifact> classpathArtifacts = getProject().getArtifacts();

    if (getClasspathDependencyScopeExclude() != null && !...isEmpty()) {
        ArtifactFilter dependencyFilter =
            new ScopeArtifactFilter(getClasspathDependencyScopeExclude());
        classpathArtifacts = filterArtifacts(classpathArtifacts, dependencyFilter);
    }
    // ...
}

Default behavior is safe — no filtering, full TEST set, correct for running tests. The danger is when someone configures classpathDependencyScopeExclude thinking it's equivalent to an independent narrower resolution.

maven-javadoc-plugin — Safe (Filters Within, Not Down)

Two goals with different scopes, each with its own ScopeDependencyFilter:

// JavadocReport — requests COMPILE, filters to compile+provided+system
@Mojo(name = "javadoc", requiresDependencyResolution = ResolutionScope.COMPILE)

protected ScopeDependencyFilter getDependencyScopeFilter() {
    return new ScopeDependencyFilter(
        Arrays.asList(SCOPE_COMPILE, SCOPE_PROVIDED, SCOPE_SYSTEM), null);
}

// TestJavadocReport — requests TEST, filters to compile+provided+system+test
@Mojo(name = "test-javadoc", requiresDependencyResolution = ResolutionScope.TEST)

protected ScopeDependencyFilter getDependencyScopeFilter() {
    return new ScopeDependencyFilter(
        Arrays.asList(SCOPE_COMPILE, SCOPE_PROVIDED, SCOPE_SYSTEM, SCOPE_TEST), null);
}

Both are safe — they filter within the scope they requested, not down to simulate a narrower resolution. The main javadoc goal requests COMPILE and includes compile+provided+system (all within COMPILE scope). test-javadoc requests TEST and includes everything it needs.

maven-dependency-plugin — The Worst Offender

Almost every goal declares ResolutionScope.TEST and then exposes includeScope / excludeScope parameters to filter the result:

Goal Scope Filters?
resolve TEST Yes — via inherited includeScope / excludeScope
list TEST Yes — extends resolve
build-classpath TEST Yes — via inherited includeScope / excludeScope
copy-dependencies TEST Yes — via inherited includeScope / excludeScope
unpack-dependencies TEST Yes — via inherited includeScope / excludeScope
analyze TEST + @Execute(TEST_COMPILE) Yes — analyzes bytecode against TEST graph
analyze-only TEST Yes — same analysis
properties TEST Yes — via inherited includeScope / excludeScope
collect TEST (collection only) Yes — via inherited includeScope / excludeScope

The filtering infrastructure lives in AbstractDependencyFilterMojo:

// All goals inherit this
protected DependencyStatusSets getDependencySets(...) {
    FilterArtifacts filter = new FilterArtifacts();

    // ...

    if ("test".equals(this.excludeScope)) {
        throw new MojoExecutionException(
            "Excluding everything: you probably meant includeScope='compile'");
    }
    filter.addFilter(new ScopeFilter(
        DependencyUtil.cleanToBeTokenizedString(this.includeScope),
        DependencyUtil.cleanToBeTokenizedString(this.excludeScope)));

    // ... more filters (type, classifier, groupId, artifactId) ...

    Set<Artifact> artifacts = filter.filter(project.getArtifacts());
}

The dangerous pattern in action:

# "Give me the runtime classpath"
mvn dependency:build-classpath -DincludeScope=runtime

# What actually happens:
# 1. Resolves at TEST scope (all deps, including test-scoped roots)
# 2. Filters the result to "runtime" scope artifacts
# 3. But the versions in that set were mediated by test-scoped
#    dependencies that wouldn't exist in a true runtime resolution

This is the canonical example of the problem. The plugin resolves the broadest scope (TEST) once, then offers includeScope/excludeScope as if they produce equivalent results to resolving at that scope directly. They don't.

dependency:analyze is also affected — it forks to TEST_COMPILE, resolves at TEST scope, then does bytecode analysis to find used/unused/undeclared dependencies. Its analysis of "compile-scoped but only used in tests" checks artifact.getScope() on artifacts whose scope may have been influenced by test-scoped dependency mediation.

Summary Table

Plugin Goal Scope Declared Filters to Narrower? Risk
compiler compile COMPILE No Safe
compiler testCompile TEST No Safe
surefire test TEST Optional (classpathDependencyScopeExclude) Risky if configured
javadoc javadoc COMPILE Within scope only Safe
javadoc test-javadoc TEST Within scope only Safe
dependency resolve TEST Yes (includeScope/excludeScope) Wrong versions possible
dependency list TEST Yes (extends resolve) Wrong versions possible
dependency build-classpath TEST Yes (includeScope/excludeScope) Wrong versions possible
dependency copy-dependencies TEST Yes (includeScope/excludeScope) Wrong versions possible
dependency unpack-dependencies TEST Yes (includeScope/excludeScope) Wrong versions possible
dependency analyze TEST + @Execute Yes (bytecode analysis) Wrong scope attribution possible
dependency properties TEST Yes (includeScope/excludeScope) Wrong versions possible
dependency collect TEST (collect) Yes (includeScope/excludeScope) Wrong versions possible

Maven 4: @Resolution — Field-Level, Type-Driven Injection

Maven 4 introduces @Resolution (MNG-8134), a field-level annotation that lets each field independently declare what kind of dependency data it needs:

import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Resolution;
import org.apache.maven.api.services.DependencyResolverResult;
import org.apache.maven.api.Node;
import org.apache.maven.api.Dependency;
import org.apache.maven.api.PathType;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;

@Mojo(name = "my-goal")
public class MyMojo implements org.apache.maven.api.plugin.Mojo {

    // Full resolution result for compile scope
    @Resolution(pathScope = "main-compile")
    private DependencyResolverResult compileResult;

    // Just the resolved file paths for runtime scope
    @Resolution(pathScope = "main-runtime")
    private List<Path> runtimePaths;

    // Paths grouped by type (classes, sources, modules, etc.)
    @Resolution(pathScope = "main-compile")
    private Map<PathType, List<Path>> pathsByType;

    // Dependency → file mapping
    @Resolution(pathScope = "test-compile")
    private Map<Dependency, Path> testDeps;

    // Collect only (no file download) — just the dependency tree
    @Resolution  // empty pathScope = collect
    private Node dependencyTree;

    // Flattened list of dependency nodes
    @Resolution(pathScope = "main-compile")
    private List<Node> flattenedDeps;

    public void execute() {
        // Each field was independently resolved/collected
        // with the correct scope — no shared mutable state
    }
}

How requestType is inferred

The annotation's behavior is driven by the field type and pathScope:

Field Type pathScope Behavior
Node "" (empty) Collect — builds the dependency tree, no files downloaded
Node or List<Node> non-empty Flatten — tree flattened to a list, scoped to the PathScope
DependencyResolverResult non-empty Resolve — full resolution with downloaded files
List<Path> non-empty Resolve — just the file paths
Map<PathType, List<Path>> non-empty Resolve — paths grouped by type
Map<Dependency, Path> non-empty Resolve — dependency-to-path mapping

You can also override the inference with requestType = "collect", "flatten", or "resolve" explicitly.

Why this is fundamentally better

  1. Per-field scoping with independent resolution — One mojo can have a @Resolution(pathScope = "main-runtime") field and a @Resolution(pathScope = "test-runtime") field. Each triggers its own independent resolution pass with its own conflict resolution. The runtime classpath is genuinely the runtime classpath — not a filtered view of the test classpath with potentially wrong versions from cross-scope mediation.

  2. Immutable results — The injected DependencyResolverResult is part of the new immutable Maven 4 API. No more mutable MavenProject.setResolvedArtifacts() shared between mojos. No last-writer-wins, no forked execution corruption.

  3. Type-safe — You declare what you need (List<Path>, Map<Dependency, Path>, the full DependencyResolverResult, or just the Node tree) and the framework gives you exactly that — no casting, no iterating through Artifact sets to extract files, no manual scope filtering.

  4. Collect without resolve@Resolution with an empty pathScope and a Node field type gives you just the dependency graph — perfect for analysis plugins that don't need to download anything.

  5. Proper reactor awareness — The new resolution infrastructure is built on Maven 4's redesigned dependency resolver API, which handles reactor modules correctly — no more stale artifacts from early-phase resolution.

Migration path

The @Resolution annotation is still marked @Experimental in Maven 4.0.0. For plugins that need to support both Maven 3 and 4, the old @Mojo(requiresDependencyResolution = ...) approach still works — Maven 4 ships with Sisu for backward compatibility. But for Maven 4-only plugins, @Resolution is the way forward.


References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment