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.
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:
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.
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
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.
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.
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.
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.
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.
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
masterbranch has already been ported to the Maven 4 API (PathScope.MAIN_COMPILE/PathScope.TEST_COMPILE+DependencyResolver).
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.
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.
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 resolutionThis 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.
| 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 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
}
}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.
-
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. -
Immutable results — The injected
DependencyResolverResultis part of the new immutable Maven 4 API. No more mutableMavenProject.setResolvedArtifacts()shared between mojos. No last-writer-wins, no forked execution corruption. -
Type-safe — You declare what you need (
List<Path>,Map<Dependency, Path>, the fullDependencyResolverResult, or just theNodetree) and the framework gives you exactly that — no casting, no iterating throughArtifactsets to extract files, no manual scope filtering. -
Collect without resolve —
@Resolutionwith an emptypathScopeand aNodefield type gives you just the dependency graph — perfect for analysis plugins that don't need to download anything. -
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.
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.
- MNG-8134: Add @Resolution annotation to mojos
- Mojo Annotations and Lifecycle (DeepWiki)
- What's New in Maven 4
- Maven Dependency Injection (DI)
- Maven 3 LifecycleDependencyResolver source
- DefaultProjectArtifactsCache API
- MNG-3283: Early dependency resolution in reactor builds
- Maven 3 for Plugin Authors (Sonatype)
- maven-dependency-plugin AbstractDependencyFilterMojo source
- maven-surefire AbstractSurefireMojo source
- maven-javadoc-plugin TestJavadocReport source