What this post covers
This is the twelfth and last post in the "Digging into Gradle's Source" series. In part 11 we saw two ways to reuse work: task outputs through the build cache and the result of the configuration phase through the configuration cache. This post follows the last area we have been putting off: how one implementation("…") line becomes the list of jar files handed to the compiler.
On the part 2 map this area was F, dependency resolution, and it was connected to the other areas with dotted lines. That is because dependency resolution is not tied to any single phase among initialization, configuration and execution. You declare dependencies in the configuration phase (area D), but they are resolved when somebody first asks for the file list — usually in the execution phase (area E). In this post we use stack traces to see exactly where that dotted line runs.
The post answers four questions.
- What does
implementation("org.apache.commons:commons-text:1.12.0")become in the source, and where is it stored? How areimplementationandcompileClasspathrelated? - When does resolution start, and who asks for it? What happens if you ask for the file list during the configuration phase?
- How is the dependency graph built, and which version wins when two versions of the same library collide? Where do
strictlyandforcestep into that decision? - Where do the metadata (POM,
.module) and the jars come from? Where on that path are--offlineand the dependency cache?
| Item | Value |
|---|---|
| Subject | Gradle Build Tool |
| Version | 9.10.0 development build (master) |
| Commit | 07ecbf9ead (2026-10-24) |
| Main classes | dsl/dependencies/DefaultDependencyHandler, configurations/DefaultConfiguration·ConfigurationRoles, ivyservice/DefaultConfigurationResolver·ResolutionExecutor, resolveengine/graph/builder/DependencyGraphBuilder·ModuleResolveState·EdgeState, resolveengine/graph/selectors/SelectorStateResolver, resolveengine/LatestModuleConflictResolver, ivyresolve/RepositoryChainComponentMetaDataResolver·CachingModuleComponentRepository |
All line numbers in this post refer to this commit. How to get the source at this commit and build the Docker image for the examples is explained in part 1. Almost every class here lives in a single subproject, platforms/software/dependency-management/, and that one subproject has more than a thousand main source files (variant selection, artifact transforms, dependency locking, verification, Ivy repositories…). This post only follows the main thread; what I left out is listed by name and location under "What this post leaves out" at the end.
Terms to know first
A module and a component both refer to a library published to a repository. group:name (for example org.apache.commons:commons-lang3) is a module; add the version, group:name:version, and you have one component. The source keeps them apart as ModuleIdentifier and ModuleComponentIdentifier.
A configuration is a named bucket that holds dependencies. implementation, compileClasspath and runtimeClasspath are all configurations. It is the same thing you see in a build script's configurations { } block, and the class in the source is DefaultConfiguration. A configuration can inherit from other configurations (extendsFrom).
Resolution turns what is declared in a configuration into a graph of real components and then into a list of files. A declaration only says "I need commons-text 1.12.0". What commons-text pulls in, and where its jar is on disk, are only known after resolution.
Metadata is the file in which a library writes down "I depend on these". In Maven repositories the POM (.pom, XML) is the standard, and libraries published with Gradle often ship Gradle Module Metadata (.module, JSON) next to it.
Variants and attributes: one component can have several faces depending on what it is used for, and each face is a variant — "the one for compiling against (API)", "the one for running (runtime)". Each variant carries attributes such as org.gradle.usage=java-api, and the resolving configuration also carries the attributes it wants; Gradle matches the two to pick a variant.
The dependency cache is where downloaded metadata and jars are kept: ~/.gradle/caches/modules-2/. It is a different cache from the build cache of part 11 (which stores task outputs).
1. The big picture: declare → graph → files
Let's start with the whole thing on one page. Going from one implementation(…) line to a list of jars takes three boxes.
Blue boxes are sections 2 and 4–7 of this post, yellow boxes are sections 3 and 7. The dotted line is why part 2 drew area F with dotted lines.
- Declare (section 2) — while the build script runs in the configuration phase, a
Dependencyobject is created and put into a configuration. That's all. Neither the network nor the disk is touched. - Resolve the graph (sections 4–6) — starting from the root (this project's configuration), Gradle reads metadata to grow the graph of "who depends on whom", and if a module has several versions it picks one. The build operation is called
Resolve dependencies of :compileClasspath. - Resolve the files (sections 4 and 7) — for each variant chosen in the graph, the actual file (jar) is found in the cache or downloaded. The build operation is called
Resolve files of configuration ':compileClasspath'.
The fact that 2 and 3 are separate comes up several times. In section 6 we will actually watch a compile fail because the graph was built fine but the jar is missing.
2. From one implementation line to a Dependency object
The generated accessor is a single add() call
You can write implementation("…") in the Kotlin DSL thanks to the generated accessors we saw in part 6. When the java plugin creates the implementation configuration, Gradle generates an extension function with that name and adds it to the script compilation. This is the template that defines that function:
fun DependencyHandler.`$kotlinIdentifier`(dependencyNotation: Any): Dependency? =
add("$stringLiteral", dependencyNotation)
The configuration name goes where $kotlinIdentifier and $stringLiteral are. So implementation("org.apache.commons:commons-text:1.12.0") is exactly dependencies.add("implementation", "org.apache.commons:commons-text:1.12.0"). The Groovy DSL's implementation '…' ends up at the same add() too (on the Groovy side, DynamicAddDependencyMethods catches the call to a method that doesn't exist and treats the name as a configuration name).
DefaultDependencyHandler.add()
The this of a dependencies { } block — the DependencyHandler — is really a DefaultDependencyHandler.
@Override
@SuppressWarnings("rawtypes")
public Dependency add(String configurationName, Object dependencyNotation, @Nullable Closure configureClosure) {
return doAdd(configurationContainer.getByName(configurationName), dependencyNotation, configureClosure);
}
First it looks up the configuration by name (getByName — if it doesn't exist, this is where you fail). Then doAdd() branches on the kind of notation.
private Dependency doAdd(Configuration configuration, Object dependencyNotation, @Nullable Closure configureClosure) {
if (dependencyNotation instanceof Configuration) {
throw new GradleException("Adding a Configuration as a dependency is no longer allowed as of Gradle 8.0.");
} else if (dependencyNotation instanceof ProviderConvertible<?>) {
return doAdd(configuration, ((ProviderConvertible<?>) dependencyNotation).asProvider(), configureClosure);
} else if (dependencyNotation instanceof ProviderInternal<?>) {
// ... (if it is a version catalog bundle, add its entries one by one)
return doAddProvider(configuration, provider, configureClosure);
} else if (dependencyNotation instanceof Provider<?>) {
return doAddProvider(configuration, (Provider<?>) dependencyNotation, configureClosure);
} else {
return doAddRegularDependency(configuration, dependencyNotation, configureClosure);
}
}
private Dependency doAddRegularDependency(Configuration configuration, Object dependencyNotation, Closure<?> configureClosure) {
Dependency dependency = create(dependencyNotation, configureClosure);
configuration.getDependencies().add(dependency);
return dependency;
}
DefaultDependencyHandler.java lines 172–198
A string goes to doAddRegularDependency() at the bottom. It creates a Dependency object with create() and just add()s it to the configuration's dependency set. A Provider, on the other hand (a version catalog's libs.xxx is one), goes to doAddProvider() and is added with addLater(). The value isn't taken out; it is recorded as "something to add later" — the same idea as the lazy configuration of part 8. That's why add() returns null in that case.
create() calls DefaultDependencyFactory.createDependency(), which hands the notation to a notation parser (DependencyNotationParser). The parser holds one converter for each kind of notation — string, map, Project, FileCollection and so on. The string converter (DependencyStringNotationConverter.java lines 58–69) splits group:name:version at the colons, reads the version string once more with StrictVersionParser (to recognize "strict version" notations like 1.0!! — the same thing as strictly in section 6), and creates the object with instantiator.newInstance(wantedType, …). wantedType is fixed to DefaultExternalModuleDependency.class in DependencyNotationParser (DependencyNotationParser.java lines 56–57).
Example 1: the returned object and the roles of the configurations
In a project with only the java plugin, I printed the object returned by implementation(…) together with the roles of the configurations the java plugin creates (section 3) (ex/u12-configurations). This example was run in a Docker container (Eclipse Temurin JDK 25) with the Gradle 9.10.0 development build built as-is from the reference commit (07ecbf9ead). The same goes for every other example in this post.
// An example written for this post (not part of Gradle's source)
plugins {
java
}
repositories { mavenCentral() }
dependencies {
// implementation(...) is a generated accessor — inside, it is dependencies.add("implementation", ...)
val dep = implementation("org.apache.commons:commons-text:1.12.0")
println("implementation(...) returned ${dep?.javaClass?.name}")
println(" group=${dep?.group} name=${dep?.name} version=${dep?.version}")
}
// print the roles (declare/resolve/consume) and extendsFrom relations of the configurations the java plugin created
tasks.register("roles") {
val rows = listOf(
"implementation", "compileOnly", "runtimeOnly",
"compileClasspath", "runtimeClasspath", "apiElements", "runtimeElements",
).map { name ->
val c = configurations.getByName(name)
// ... (c.isCanBeDeclared, c.isCanBeResolved, c.isCanBeConsumed, c.extendsFrom, c.javaClass.simpleName on one line)
}
doLast { rows.forEach { println(it) } }
}
// ... the resolveImplementation task comes in section 3 ...
The output of gradle roles --offline --console=plain -q:
implementation(...) returned org.gradle.api.internal.artifacts.dependencies.DefaultExternalModuleDependency_Decorated
group=org.apache.commons name=commons-text version=1.12.0
implementation declare=true resolve=false consume=false extendsFrom=[] (DefaultDependencyScopeConfiguration_Decorated)
compileOnly declare=true resolve=false consume=false extendsFrom=[] (DefaultDependencyScopeConfiguration_Decorated)
runtimeOnly declare=true resolve=false consume=false extendsFrom=[] (DefaultDependencyScopeConfiguration_Decorated)
compileClasspath declare=false resolve=true consume=false extendsFrom=[compileOnly, implementation] (DefaultResolvableConfiguration_Decorated)
runtimeClasspath declare=false resolve=true consume=false extendsFrom=[implementation, runtimeOnly] (DefaultResolvableConfiguration_Decorated)
apiElements declare=false resolve=false consume=true extendsFrom=[] (DefaultConsumableConfiguration_Decorated)
runtimeElements declare=false resolve=false consume=true extendsFrom=[implementation, runtimeOnly] (DefaultConsumableConfiguration_Decorated)
The class on the first line is DefaultExternalModuleDependency_Decorated — the _Decorated subclass from part 4, a class the Instantiator generated with ASM. Even a single dependency goes through Gradle's object-creation machinery, just like a task. And this object holds nothing but the version string 1.12.0. Nobody knows yet what commons-text pulls in.
The code in this section joined into one path. The class name and values in the green box are example 1's actual output. Nothing has been downloaded yet.
Easy to get confused
- Why
implementation(…)returnsDependency?is theProviderpath above. Pass a version catalog entry (libs.commons.text) and you getnull. Code that does something with the returned value only works with string notation. - The old way of adding a configuration as a dependency (
implementation(configurations["foo"])) throws right away in the first branch ofdoAdd(). As the message says, it "is no longer allowed as of Gradle 8.0". UseextendsFrominstead (section 3).
3. Configurations — buckets for dependencies, with three roles
In Example 1's output, the three values declare, resolve and consume differed by configuration. There are three things a configuration can do.
- Declare — the build script puts dependencies into it.
- Resolve — turn what it holds (including what it inherits) into a graph and files.
- Consume — be a variant that other projects see when they depend on this project.
In older Gradle a single configuration could do all three, which made it easy to confuse "the bucket you put things into" with "the bucket you resolve". Today, predefined combinations of roles live in ConfigurationRoles.
public static final ConfigurationRole CONSUMABLE = createNonDeprecatedRole("Consumable", true, false, false);
// ...
public static final ConfigurationRole RESOLVABLE = createNonDeprecatedRole("Resolvable", false, true, false);
// ... (RESOLVABLE_DEPENDENCY_SCOPE, CONSUMABLE_DEPENDENCY_SCOPE — both @Deprecated)
public static final ConfigurationRole DEPENDENCY_SCOPE = createNonDeprecatedRole("Dependency Scope", false, false, true);
The three booleans are in the order (consumable, resolvable, declarable) (the parameters of createNonDeprecatedRole on line 85 of the same file). The class names in Example 1 — DefaultDependencyScopeConfiguration, DefaultResolvableConfiguration, DefaultConsumableConfiguration — match these three one to one. All three are subclasses of DefaultConfiguration.
The configuration tree the java plugin builds
implementation and compileClasspath are created and wired together in JavaBasePlugin, once per source set (main, test).
private void defineConfigurationsForSourceSet(SourceSet sourceSet, RoleBasedConfigurationContainerInternal configurations) {
// ... (get the configuration names from the source set)
Configuration implementationConfiguration = configurations.dependencyScopeLocked(implementationConfigurationName, conf -> {
conf.setDescription("Implementation only dependencies for " + sourceSetName + ".");
});
// ... (compileOnly has the same shape)
Configuration compileClasspathConfiguration = configurations.resolvableLocked(compileClasspathConfigurationName, conf -> {
conf.extendsFrom(compileOnlyConfiguration, implementationConfiguration);
conf.setDescription("Compile classpath for " + sourceSetName + ".");
jvmPluginServices.configureAsCompileClasspath(conf);
});
implementation is created with dependencyScopeLocked (declare only), compileClasspath with resolvableLocked (resolve only), and extendsFrom connects them. Further down the same method (lines 322–330), runtimeOnly and runtimeClasspath are created the same way, and runtimeClasspath inherits from runtimeOnly and implementation. The Locked suffix means the role can't be changed after creation. configureAsCompileClasspath() attaches the attributes this configuration asks for — library, API usage (java-api), with external dependencies, standard JVM (DefaultJvmPluginServices.java lines 61–66). These attributes are used to pick variants in section 5.
Dependencies go in on the left; resolution happens on the right. Solid arrows are extendsFrom (the configuration an arrow points to inherits from the one it starts at); the dotted arrow is the exception you get when you try to resolve.
Example 1 (continued): resolving a declare-only configuration
What happens if you resolve the bucket you put things into? I added one more task to Example 1.
// what if we try to resolve a configuration that can't be resolved (implementation)?
tasks.register("resolveImplementation") {
val impl = configurations.getByName("implementation")
doLast { println(impl.files) }
}
* What went wrong:
Execution failed for task ':resolveImplementation' (registered in build file 'build.gradle.kts').
> Resolving dependency configuration 'implementation' is not allowed as it is defined as 'canBeResolved=false'.
Instead, a resolvable ('canBeResolved=true') dependency configuration that extends 'implementation' should be resolved.
(I dropped the two implementation(...) returned … lines and the FAILURE: Build failed with an exception. line before it, and the * Try: hints after it.) The message is thrown by DefaultConfiguration.assertIsResolvable() when you try to resolve a configuration whose canBeResolved is false (platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/configurations/DefaultConfiguration.java lines 1409–1413). As the message says, if you need the files of implementation, resolve compileClasspath or runtimeClasspath, which inherit from it. The design separates "what did you put in" from "for what purpose do you take it out". The same implementation can give different results when taken out for compiling (the java-api variant) and for running (the java-runtime variant), so resolution always happens on a configuration that has a purpose (attributes).
Easy to get confused
apiElementsinherits nothing. The output showsextendsFrom=[]. With only thejavaplugin there is noapiconfiguration, so the variant other projects see when compiling against this project carries none of this project's dependencies.implementationonly flows into my own compile and runtime, and into others' runtime (runtimeElements). Theapiconfiguration is created when thejava-libraryplugin callswithApi(), and at that pointapiElementsstarts inheriting fromapi(DefaultJvmFeature.java lines 261–271).annotationProcessoris an exception. TheannotationProcessorI cut out of the excerpt above is created withresolvableDependencyScopeLocked, which both declares and resolves, and that's why it carries@SuppressWarnings("deprecation")(lines 316–317). The role split isn't finished there yet.
4. When does resolution happen? — lazy resolution
Nothing has been resolved so far. So when does resolution happen? The answer is in DefaultConfiguration. A configuration keeps its resolution result in currentResolveState and calls resolveGraphIfRequired() when it needs the result (DefaultConfiguration.java lines 639–660). If it has already been resolved (isFullyResolved), the result is returned; otherwise it resolves. Within one build a configuration is resolved only once (the internal callAndResetResolutionState(), which rolls the state back, is the exception). The hasMutableState() check in the middle makes sure only a thread holding the project lock from part 6 can resolve (otherwise you get a "Resolution of the … was attempted without an exclusive lock" exception). The actual resolution is resolveGraphInBuildOperation(), wrapped in a build operation.
private ResolverResults resolveGraphInBuildOperation() {
return configurationServices.getBuildOperationRunner().call(new CallableBuildOperation<ResolverResults>() {
@Override
public ResolverResults call(BuildOperationContext context) {
runDependencyActions();
dependencyResolutionListeners.getSource().beforeResolve(getIncoming());
ResolverResults results;
try {
results = resolver.resolveGraph(DefaultConfiguration.this);
} catch (Exception e) {
throw configurationServices.getExceptionMapper().mapFailure(e, "dependencies", displayName.getDisplayName());
}
// Make the new state visible in case a dependency resolution listener queries the result, which requires the new state
currentResolveState.set(Optional.of(results));
dependencyResolutionListeners.getSource().afterResolve(getIncoming());
// ...
DefaultConfiguration.java lines 675–692
beforeResolve listeners → resolver.resolveGraph(this) → store the result → afterResolve listeners. The build operation is named "Resolve dependencies of " + identityPath (line 719). So who calls resolveGraphIfRequired()? Printing a stack trace is faster than tracing the source backwards.
Example 2: who starts resolution?
I attached beforeResolve and afterResolve listeners to compileClasspath, and with -DdumpStack the beforeResolve listener prints the stack (ex/u12-resolve-timing).
// An example written for this post (not part of Gradle's source)
// ... (plugins { java }, mavenCentral(), implementation("org.apache.commons:commons-text:1.12.0"))
// print when, on which thread, and at whose request resolution happens
configurations.compileClasspath {
incoming.beforeResolve {
println(">> beforeResolve '$name' (thread=${Thread.currentThread().name})")
if (providers.systemProperty("dumpStack").isPresent) {
Throwable().stackTrace
.filter { it.className.startsWith("org.gradle") }
.forEach { println(" at ${it.className.substringAfterLast('.')}.${it.methodName}") }
}
}
incoming.afterResolve {
println("<< afterResolve '$name': ${resolutionResult.allComponents.map { it.id.displayName }}")
}
}
println("end of build.gradle.kts: compileClasspath state = ${configurations.compileClasspath.get().state}")
// with -Pearly, ask for the file list during the configuration phase
if (providers.gradleProperty("early").isPresent) {
println("asking for files during configuration...")
println("files = ${configurations.compileClasspath.get().files.map { it.name }}")
// what if we add a dependency after resolution?
dependencies.add("implementation", "org.slf4j:slf4j-api:2.0.16")
}
First, gradle help -q, which has nothing to do with the classpath:
end of build.gradle.kts: compileClasspath state = UNRESOLVED
beforeResolve was never printed. Nobody asked for the file list, so nothing was resolved. Next, gradle compileJava -DdumpStack. The stack is 118 lines, so I cut it down to the meaningful lines (... marks the cuts).
> Configure project :
end of build.gradle.kts: compileClasspath state = UNRESOLVED
> Task :compileJava
>> beforeResolve 'compileClasspath' (thread=Execution worker Thread 2)
...
at DefaultConfiguration.resolveGraphInBuildOperation
at DefaultConfiguration.lambda$resolveExclusivelyIfRequired$0
at DefaultCalculatedModelValue.update
at DefaultConfiguration.resolveExclusivelyIfRequired
at DefaultConfiguration.resolveGraphIfRequired
...
at ResolutionBackedFileCollection.visitContents
at AbstractFileCollection.visitStructure
at DefaultConfiguration.visitContents
...
at DefaultFileCollectionSnapshotter.snapshot
at DefaultInputFingerprinter$InputCollectingVisitor.visitInputFileProperty
at TaskExecution.visitMutableInputs
at DefaultInputFingerprinter.fingerprintInputProperties
at CaptureMutableStateBeforeExecutionStep.captureExecutionStateWithOutputs
...
at CaptureMutableStateBeforeExecutionStep.execute
at SkipEmptyMutableWorkStep.executeWithNonEmptySources
...
at LoadPreviousExecutionStateStep.executeMutable
...
at IdentifyStep.execute
at DefaultExecutionEngine$1.execute
at DefaultTaskNodeExecutor.doExecute
...
at LocalTaskNode.execute
...
at DefaultPlanExecutor$ExecutorWorker.run
...
<< afterResolve 'compileClasspath': [root project 'resolve-timing', org.apache.commons:commons-text:1.12.0, org.apache.commons:commons-lang3:3.14.0]
The number after the thread name (Thread 2) differs from run to run (when I ran the same example again, it was a plain Execution worker). Read from the bottom up:
- A worker thread of
DefaultPlanExecutorpicks up thecompileJavanode (part 9), andDefaultTaskNodeExecutorhands it to the execution engine. - Going down the engine's step chain (part 10),
CaptureMutableStateBeforeExecutionStepfingerprints the input files before running the task. To decide whether it's UP-TO-DATE, it has to know what the inputs are. compileJava'sclasspathinput contains thecompileClasspathconfiguration (DefaultConfigurationis itself aFileCollection—extends AbstractFileCollectionon line 143). The moment its contents are visited (visitContents) for fingerprinting,resolveGraphIfRequired()is called.
In other words, resolution was started not by javac but by part 10's execution engine. It finishes in the input-fingerprinting step, before the task action runs. Picking just the names out of the build operation trace of the same run (-Dorg.gradle.internal.operations.trace=/tmp/trace) shows that graph resolution and file resolution are separate:
202:{"displayName":"Resolve dependencies of :compileClasspath", ... "id":217,"parentId":216, ...}
324:{"displayName":"Resolve files of configuration ':compileClasspath'", ... "id":218,"parentId":216, ...}
330:{"displayName":"Resolve dependencies of :annotationProcessor", ... "id":221,"parentId":216, ...}
337:{"displayName":"Resolve files of configuration ':compileClasspath'", ... "id":224,"parentId":223, ...}
(The number at the start of each line is the line number in the trace file; values like id and timestamps differ per run. I shortened each line's long details.) ② and ③ in the section 1 diagram really are different build operations. The annotationProcessor configuration is also an input of compileJava, so it is resolved at the same time. The second Resolve files of configuration ':compileClasspath' on line 337 has a different parent — when I reran it and followed the parents, it sat under the task action's operation (Execute compile for :compileJava). That is the files being taken out once more to hand to javac; graph resolution (Resolve dependencies of) did not happen again. The trace also contains Resolve dependencies of classpath; that is the resolution of the build script's own classpath (the classpath configuration, "scriptConfiguration":true in the trace).
⑥ is where the error in the next example comes from; ⑧ is the loop we read in sections 5 and 6.
Example 2 (continued): asking for files in the configuration phase
With -Pearly, the build script body calls files and then adds one more dependency.
end of build.gradle.kts: compileClasspath state = UNRESOLVED
asking for files during configuration...
>> beforeResolve 'compileClasspath' (thread=Daemon worker)
<< afterResolve 'compileClasspath': [root project 'resolve-timing', org.apache.commons:commons-text:1.12.0, org.apache.commons:commons-lang3:3.14.0]
files = [commons-text-1.12.0.jar, commons-lang3-3.14.0.jar]
FAILURE: Build failed with an exception.
* Where:
Build file '/tmp/p/build.gradle.kts' line: 34
* What went wrong:
Cannot mutate the dependencies of configuration ':implementation' after the configuration's child configuration ':compileClasspath' was resolved. After a configuration has been observed, it should not be modified.
(I dropped the * Try: hints and BUILD FAILED in 2s at the end. The time differs per run.) Two things show up. First, resolution itself works — but this time on the Daemon worker thread that runs the configuration phase, and on every invocation, even ones like gradle help that don't need the classpath. Second, after resolution even the configuration it inherits from (implementation) is locked. At ⑥ in the diagram, DefaultConfigurationResolver.resolveGraph() builds the root variant and calls finalize(configuration, "resolved") (DefaultLocalVariantGraphResolveStateBuilder.java line 95), which calls markAsObserved() (DefaultConfiguration.java lines 1007–1028). Through runActionInHierarchy, that method records a reason like "the configuration's child … was resolved" on itself and on every configuration it inherits from. So implementation gets the reason "its child :compileClasspath was resolved", and when you later try to change its dependencies, validateMutation() takes that reason out and builds the exception message (lines 1228–1239). It is a guard that keeps an already-built graph and the configuration contents from drifting apart.
Easy to get confused
getState()doesn't trigger resolution. Printingstatein the example didn't callbeforeResolve. Calls that look at the contents —files,iterator(),resolve(), taking the value out ofincoming.resolutionResult— do trigger it. If you pass the configuration (or aProvider) to a task as an input and take the value out insidedoLast, resolution is pushed to the execution phase as in this example.- A resolution result is used only within one build. The next build resolves again (though thanks to the dependency cache in section 7, nothing is downloaded again). Things change with the configuration cache turned on, but that is part 11's territory.
5. Building the graph — DependencyGraphBuilder
resolver.resolveGraph() continues from DefaultConfigurationResolver to ResolutionExecutor.resolveGraph(). ResolutionExecutor prepares the visitors that receive the result (graph structure, failure collection, artifacts) and builds the resolver chain (ComponentResolversChain) used to find components (platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/ResolutionExecutor.java lines 443–468). The resolver for projects in the same build (project(":core")) comes first, and the resolver for external modules in repositories comes after it. Then, through DependencyGraphResolver, we reach the body of graph building, DependencyGraphBuilder.resolve(). That method boils down to three lines — traverseGraph() (grow the graph), validateGraph() (check it) and assembleResult() (hand the result to the visitors) (DependencyGraphBuilder.java lines 162–166).
What the graph is made of
Before reading traverseGraph(), let's line up the cast. All of them live in the resolveengine/graph/builder/ package.
| Class | What it is in the graph | In our example |
|---|---|---|
ResolveState |
One whole resolution. Holds the queue of nodes to process and the module table | One resolution of compileClasspath |
ModuleResolveState |
One module (group:name). Collects every request for that module |
org.apache.commons:commons-lang3 |
SelectorState |
One request — "this version constraint for this module" | The directly declared 3.12.0, the 3.14.0 written in commons-text's POM |
ComponentState |
One candidate with a fixed version | commons-lang3:3.14.0 |
NodeState |
One variant of a chosen component. A node of the graph | commons-lang3's compile variant |
EdgeState |
One outgoing dependency of a node. An edge of the graph | commons-text node → the commons-lang3 request |
A node is not "a component" but "a variant of a component". NodeState holds a VariantGraphResolveState field.
traverseGraph() — until the queue is empty
private void traverseGraph(final ResolveState resolveState) {
resolveState.onMoreSelected(resolveState.getRoot());
final List<EdgeState> edges = new ArrayList<>();
ModuleConflictHandler moduleConflictHandler = resolveState.getModuleConflictHandler();
CapabilitiesConflictHandler capabilitiesConflictHandler = resolveState.getCapabilitiesConflictHandler();
while (resolveState.peek() != null || moduleConflictHandler.hasConflicts() || capabilitiesConflictHandler.hasConflicts()) {
if (resolveState.peek() != null) {
final NodeState node = resolveState.pop();
if (!node.shouldBuildSubgraph()) {
node.removeOutgoingEdges();
continue;
}
// ... (on a capability conflict it has to be resolved first, so continue)
// This node is part of the graph and is not in conflict. Process its outgoing dependencies.
edges.clear();
node.visitOutgoingDependenciesAndCollectEdges(edges);
resolveEdges(node, edges, resolveState);
} else {
// We have some batched up conflicts. Resolve the first, and continue traversing the graph
if (moduleConflictHandler.hasConflicts()) {
moduleConflictHandler.resolveNextConflict();
} else {
capabilitiesConflictHandler.resolveNextConflict();
}
}
}
}
It isn't recursion but a loop over a queue. It starts with the root node in the queue, takes a node out, collects its outgoing dependencies (edges) with visitOutgoingDependenciesAndCollectEdges, and settles the targets of those edges with resolveEdges(). Nodes newly attached to the graph go back into the queue. When the queue is empty, it resolves one postponed conflict and loops again.
resolveEdges() has three steps (lines 210–222).
performSelectionSerially()— for each edge, (re)select the version of the target module. Section 6's conflict resolution happens here.maybeDownloadMetadataInParallel()— if two or more chosen components can't get their metadata "cheaply" (they need a remote download), fetch them in parallel. As the comment says, since "downloading is IO bound", it allows more parallelism than there are worker leases (line 289).attachToTargetRevisionsSerially()— attach the edges to the target nodes. The comment explains why this is done on one thread: so that the graph is built in the same order every time (reproducibly) (lines 297–299).
Example 3: the stack at the moment metadata is read
To look into the middle of the graph, I registered a component metadata rule (ComponentMetadataRule — a rule that gets a chance to adjust a module's metadata right after it is read) and printed the stack inside it (ex/u12-offline-cache). Only the classes that appear in this post are kept.
// An example written for this post (not part of Gradle's source)
// a rule called while the graph is being built, right after a module's metadata (POM, .module) is read
abstract class WhereAmIRule : ComponentMetadataRule {
override fun execute(context: ComponentMetadataContext) {
val id = context.details.id
println("metadata rule: $id")
if (id.name == "commons-text") {
val keep = setOf(
"DefaultConfiguration", "DefaultConfigurationResolver", "ResolutionExecutor",
// ... (seven more class names that appear in this post)
)
Throwable().stackTrace
.map { it.className.substringAfterLast('.') to it.methodName }
.filter { it.first in keep }
.forEach { (c, m) -> println(" at $c.$m") }
}
}
}
dependencies {
components { all<WhereAmIRule>() }
implementation("org.apache.commons:commons-text:1.12.0")
implementation("com.google.guava:guava:33.3.1-jre")
// ... (a declaration used only with -Pmissing, see section 7)
}
// ... (plugins, repositories, printClasspath and variants tasks)
The matching lines from the output of gradle printClasspath --offline --info:
metadata rule: org.apache.commons:commons-text:1.12.0
at CachingModuleComponentRepository$LocateInCacheRepositoryAccess.getProcessedMetadata
at CachingModuleComponentRepository$LocateInCacheRepositoryAccess.estimateMetadataFetchingCost
at RepositoryChainComponentMetaDataResolver.isFetchingMetadataCheap
at DependencyGraphBuilder.maybeDownloadMetadataInParallel
at DependencyGraphBuilder.resolveEdges
at DependencyGraphBuilder.traverseGraph
at DependencyGraphBuilder.resolve
at DependencyGraphResolver.resolve
at ResolutionExecutor.doResolve
at ResolutionExecutor.resolveGraph
at DefaultConfigurationResolver.resolveGraph
at DefaultConfiguration.resolveGraphInBuildOperation
at DefaultConfiguration.lambda$resolveExclusivelyIfRequired$0
at DefaultConfiguration.resolveExclusivelyIfRequired
at DefaultConfiguration.resolveGraphIfRequired
at DefaultConfiguration.visitContents
metadata rule: com.google.guava:guava:33.3.1-jre
metadata rule: org.apache.commons:commons-lang3:3.14.0
metadata rule: com.google.guava:failureaccess:1.0.2
metadata rule: com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
metadata rule: com.google.code.findbugs:jsr305:3.0.2
metadata rule: org.checkerframework:checker-qual:3.43.0
metadata rule: com.google.errorprone:error_prone_annotations:2.28.0
metadata rule: com.google.j2objc:j2objc-annotations:3.0.0
⑤–⑧ of the diagram are right there on the stack. It comes through traverseGraph → resolveEdges → maybeDownloadMetadataInParallel, and while asking "is fetching this metadata cheap?" (isFetchingMetadataCheap) it took the metadata out of the cache (LocateInCacheRepositoryAccess). Because it's in the cache, it is judged cheap and no parallel download happens. The order in which the rule is called is the queue order: the two the root declares directly (commons-text, guava) come first, then what they pull in.
A taste of variant selection
We said a node is a variant of a component. To see which variants were chosen, I added a variants task that goes through the direct dependencies of the root component in the resolution result and prints the name and Usage attribute of each ResolvedDependencyResult.resolvedVariant (ResolvedVariantResult) (gradle variants --offline -q).
org.apache.commons:commons-text:1.12.0 -> variant 'compile' (usage=java-api)
com.google.guava:guava:33.3.1-jre -> variant 'jreApiElements' (usage=java-api)
Both got a variant with the java-api usage that compileClasspath asked for, but the names differ. commons-text only has a POM, so Gradle made up the variants itself — while reading the POM it creates configurations such as compile and runtime, one per Maven scope (GradlePomModuleDescriptorBuilder.java lines 52–58), and JavaEcosystemVariantDerivationStrategy.derive() turns them into eight variants: library compile and runtime, sources, javadoc, and four platform variants (JavaEcosystemVariantDerivationStrategy.java lines 43–65), while guava's publisher wrote its variants directly into a .module file (jreApiElements — guava publishes separate variants for the JRE and for Android). How the .module is found is section 7. Variant selection itself (GraphVariantSelector, the attribute matching rules) would need a post of its own, so we stop here.
Easy to get confused
- Graph resolution doesn't need jars. Everything read so far is metadata. Jars are looked up in file resolution (
Resolve files of configuration) after the graph is complete. At the end of section 6 this difference shows up as a failure. - A node can be processed again. When a version selection changes (section 6), the edges of the losing node are taken down and reattached to the winner (
restartIncomingEdges()inModuleResolveState.changeSelection()). So you can't claim that "each node enters the queue only once".
6. Two versions of the same module — conflict resolution
This series' Docker image deliberately contains a set of libraries that collide. commons-text 1.12.0 pulls in commons-lang3 3.14.0, and the same build declares commons-lang3 3.12.0 directly.
Example 4: dependencies and dependencyInsight
-Pmode picks one of three variations of the same build (ex/u12-conflict).
// An example written for this post (not part of Gradle's source)
plugins {
java
}
repositories { mavenCentral() }
// -Pmode=default|strict|force picks one of three variations of the same build
val mode = providers.gradleProperty("mode").getOrElse("default")
dependencies {
// commons-text 1.12.0 pulls in commons-lang3 3.14.0
implementation("org.apache.commons:commons-text:1.12.0")
if (mode == "strict") {
// the direct declaration insists "it must be this version"
implementation("org.apache.commons:commons-lang3") {
version { strictly("3.12.0") }
}
} else {
// just declare a lower version directly
implementation("org.apache.commons:commons-lang3:3.12.0")
}
}
if (mode == "force") {
// the resolution strategy rewrites every commons-lang3 request to 3.12.0
configurations.compileClasspath {
resolutionStrategy.force("org.apache.commons:commons-lang3:3.12.0")
}
}
tasks.register("printClasspath") {
val cp = configurations.compileClasspath
doLast { cp.get().files.forEach { println(it.name) } }
}
First, gradle dependencies --configuration compileClasspath --offline --console=plain on the default variation. It was the first command in the container, so the daemon start-up lines come along.
Welcome to Gradle 9.10.0-20261107025045+0000!
Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
> Task :dependencies
------------------------------------------------------------
Root project 'conflict'
------------------------------------------------------------
compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-text:1.12.0
\--- org.apache.commons:commons-lang3:3.14.0
\--- org.apache.commons:commons-lang3:3.12.0 -> 3.14.0
(I dropped the lines below it — the (*) explanation, the --scan hint, BUILD SUCCESSFUL in 1m 1s and so on. Times differ per run.) Shared read-only dependency cache is an incubating feature. is printed because of the read-only dependency cache this image uses (section 7). The last line, 3.12.0 -> 3.14.0, means "3.12.0 was requested, but 3.14.0 was chosen". dependencyInsight tells us why (gradle dependencyInsight --dependency commons-lang3 --configuration compileClasspath --offline --console=plain -q).
org.apache.commons:commons-lang3:3.14.0
Variant compile:
Attribute Name | Provided | Requested |
--------------------------------|----------|--------------|
org.gradle.status | release | |
org.gradle.category | library | library |
org.gradle.libraryelements | jar | classes |
org.gradle.usage | java-api | java-api |
org.gradle.dependency.bundling | | external |
org.gradle.jvm.environment | | standard-jvm |
org.gradle.jvm.version | | 25 |
Selection reasons:
- By conflict resolution: between versions 3.14.0 and 3.12.0
org.apache.commons:commons-lang3:3.14.0
\--- org.apache.commons:commons-text:1.12.0
\--- compileClasspath
org.apache.commons:commons-lang3:3.12.0 -> 3.14.0
\--- compileClasspath
(I dropped the one-line --scan hint at the end.) The Requested column holds the attributes the compileClasspath configuration carries (those attached by configureAsCompileClasspath() in section 3, plus ones like org.gradle.jvm.version that the java plugin attaches separately), and Provided holds the attributes of the compile variant derived from the POM. Below it, the reason is By conflict resolution: between versions 3.14.0 and 3.12.0. Let's find the code that writes that sentence.
When requests pile up, the version is chosen again
Section 5's performSelectionSerially() calls maybeUpdateSelection() on each edge's target module. Two requests pile up on the commons-lang3 module (ModuleResolveState): the root's direct declaration (3.12.0) and commons-text's POM (3.14.0).
public void maybeUpdateSelection() {
if (replaced) {
// Never update selection for a replaced module
return;
}
if (!rootModule && selectors.checkDeferSelection()) {
// Selection deferred as we know another selector will be added soon
return;
}
ComponentState newSelected = selectorStateResolver.selectBest(getId(), selectors);
newSelected.setSelectors(selectors);
if (selected == null) {
select(newSelected);
} else if (newSelected != selected) {
// ... (safety net: if the selection changes more than 1000 times, settle on the highest version)
changeSelection(newSelected);
}
}
Every time a new request arrives, it chooses again looking at all collected requests (selectBest(getId(), selectors)). Even if 3.12.0 was chosen first, once the 3.14.0 request arrives it chooses again and switches the selection (changeSelection). SelectorStateResolver.selectBest() finds the candidates for each request; if there is one candidate it takes it, and if there are several it moves on to conflict resolution (lines 64–95). Here is that conflict resolution:
private T resolveConflicts(Collection<T> candidates) {
// Do conflict resolution to choose the best out of current selection and candidate.
ConflictResolverDetails<T> details = new DefaultConflictResolverDetails<>(candidates);
conflictResolver.select(details);
T selected = details.getSelected();
if (details.hasFailure()) {
throw UncheckedException.throwAsUncheckedException(details.getFailure());
} else {
ComponentSelectionDescriptorInternal desc = ComponentSelectionReasons.CONFLICT_RESOLUTION;
selected.addCause(desc.withDescription(new VersionConflictResolutionDetails(candidates)));
}
return selected;
}
It attaches a CONFLICT_RESOLUTION reason to the chosen component, and the description (VersionConflictResolutionDetails) builds the "between versions …" sentence (line 49 of graph/conflicts/VersionConflictResolutionDetails.java). That's where dependencyInsight's reason line comes from. The choosing is done by conflictResolver. The method that sets it up, DependencyGraphResolver.createModuleConflictResolver() (resolveengine/DependencyGraphResolver.java lines 145–151), always creates a LatestModuleConflictResolver, and only when resolutionStrategy.preferProjectModules() is turned on does it wrap it in a "prefer projects of this build" wrapper (ProjectDependencyForcingResolver). As the name says, LatestModuleConflictResolver picks the highest version.
@Override
public void select(ConflictResolverDetails<T> details) {
// Find the candidates with the highest base version
Version baseVersion = null;
Map<Version, T> matches = new LinkedHashMap<>();
for (T candidate : details.getCandidates()) {
Version version = versionParser.transform(candidate.getVersion());
if (baseVersion == null || versionComparator.compare(version.getBaseVersion(), baseVersion) > 0) {
matches.clear();
baseVersion = version.getBaseVersion();
matches.put(version, candidate);
} else if (version.getBaseVersion().equals(baseVersion)) {
matches.put(version, candidate);
}
}
if (matches.size() == 1) {
details.select(matches.values().iterator().next());
return;
}
// ...
It gathers the candidates with the highest "base version" (for 3.14.0-RC1 the base version is 3.14.0). If there's only one, it's done; if several share the same base version, like 3.14.0 and 3.14.0-RC1, the omitted rest of the code picks in the order: unqualified release version, release status, snapshot. Our example has 3.14.0 and 3.12.0, so it ends in the first part with 3.14.0.
This is where Gradle differs from Maven. Maven (according to the Maven documentation) picks the declaration nearest to the root in the graph; in the same example the directly declared 3.12.0 would win. Gradle doesn't look at distance; it picks the highest version. The surprise of "I declared it myself, so why did a different version come in?" usually comes from this difference. (I did not check the Maven side against Maven's source for this post.)
What does DefaultModuleConflictHandler do?
Section 5's traverseGraph() also had moduleConflictHandler.resolveNextConflict(). Judging by the name you might expect the version conflict above to be resolved there, but as we just saw, choosing the version of one module happens immediately in SelectorStateResolver whenever requests pile up. DefaultModuleConflictHandler gets a module registered as a candidate when it is first selected (registerCandidate in performSelection(), DependencyGraphBuilder.java lines 248–264), and mainly queues up conflicts between modules (registerCandidate(), resolveengine/graph/conflicts/DefaultModuleConflictHandler.java lines 56–70).
moduleReplacements are declarations like dependencies.modules { module("a:old") { replacedBy("a:new") } } — "this module has been replaced by that one". If both appear in the graph, the conflict is registered and, after the queue empties, resolveNextConflict() picks one. The tool used for picking is the same LatestModuleConflictResolver — ResolveState creates the conflict handler (line 142) and passes that very handler's resolver to SelectorStateResolver (line 163).
strictly and force
Here is dependencies run with -Pmode=strict (the header lines identical to the default variation, and the (*) explanation and --scan hint at the end, are dropped):
compileClasspath - Compile classpath for source set 'main'.
+--- org.apache.commons:commons-text:1.12.0
\--- org.apache.commons:commons-lang3:3.14.0 -> 3.12.0
\--- org.apache.commons:commons-lang3:{strictly 3.12.0} -> 3.12.0
This time the 3.14.0 that commons-text requested was turned into 3.12.0. The first line of dependencyInsight is org.apache.commons:commons-lang3:3.12.0 (by ancestor) (the attribute table is the same as before, so I leave it out). It means a version decided by an ancestor. Two pieces of code produce this. First, strictly becomes a constraint meaning "reject every version except this one": when there is a strict version, getRejectionForStrict() builds a rejection selector from the complement of that version (platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/dependencies/DefaultResolvedVersionConstraint.java lines 46–57, complementForRejection on lines 67–70). And this strict version is passed down. A node carries the list of modules its ancestors made strict (ancestorsStrictVersions) and looks at it when it computes the selector of an edge.
boolean computeSelector(StrictVersionConstraints ancestorsStrictVersions, boolean deferSelection) {
boolean ignoreVersion = !isForced() && ancestorsStrictVersions.contains(getTargetModuleIdentifier());
ComponentSelector componentSelector = dependencyMetadata.getSelector();
SelectorState newSelector = resolveState.computeSelectorFor(componentSelector, ignoreVersion);
// ...
Because an ancestor (here, the root) made commons-lang3 strict, the commons-lang3 edge going out of the commons-text node gets a selector that ignores the version (ignoreVersion). The only remaining version constraint is the root's strictly 3.12.0, so no conflict arises at all. The by ancestor reason is attached on this edge as well (EdgeState.getMainReason(), lines 582–584).
With -Pmode=force, the first line of dependencyInsight is org.apache.commons:commons-lang3:3.12.0 (forced). force takes a completely different path. The resolution strategy creates one more dependency substitution rule (DefaultResolutionStrategy.java lines 237–241).
@Override
public void execute(DependencySubstitutionInternal details) {
if (forcedModules == null) {
return;
}
if (details.getRequested() instanceof ModuleComponentSelector) {
ModuleComponentSelector selector = (ModuleComponentSelector) details.getRequested();
ModuleIdentifier key = selector.getModuleIdentifier();
if (forcedModules.containsKey(key)) {
DefaultImmutableVersionConstraint versionConstraint = new DefaultImmutableVersionConstraint(forcedModules.get(key));
details.useTarget(newSelector(key, versionConstraint, selector.getAttributes(), selector.getCapabilitySelectors()), ComponentSelectionReasons.FORCED);
}
}
}
It rewrites every request in the graph that points at commons-lang3 into a 3.12.0 request (useTarget). All requests are 3.12.0, so again there is no conflict. To sum up the difference:
strictly is a constraint that travels down the graph; force is a substitution rule attached to the resolution strategy.
| Where you write it | How it wins | dependencyInsight |
|
|---|---|---|---|
| Default | — | Highest version (LatestModuleConflictResolver) |
By conflict resolution: between versions … |
strictly |
Version constraint of a dependency declaration | Makes edges further down ignore their versions | (by ancestor) |
force |
The configuration's resolutionStrategy |
Rewrites every request for that module | (forced) |
strictly is attached to one dependency declaration and only affects the graph below it. force is attached to a configuration and affects that configuration's whole resolution (in the example it was put only on compileClasspath). Kafka's build uses exactly this force — we'll see it in "Wrapping up the series".
Easy to get confused — the graph is fine, but the jar is missing
The last step of Example 4 runs compileJava with -Pmode=strict.
* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':compileClasspath'.
> Could not download commons-lang3-3.12.0.jar (org.apache.commons:commons-lang3:3.12.0): No cached version available for offline mode
(I dropped the FAILURE: … line before it and the * Try: hints and BUILD FAILED in 3s after it.) With the same settings, dependencies and dependencyInsight happily showed 3.12.0, yet compilation failed. When this image was built, the warmup build ran with the default variation, so 3.12.0 lost the conflict and its jar was never downloaded (the cache only has 3.12.0's POM — its metadata was read to build the graph). The report tasks only go as far as graph resolution, while compileJava needs file resolution too, and that's where they part ways. It's a failure that shows ② and ③ of section 1 are different steps. The default variation's compileJava was BUILD SUCCESSFUL, and printClasspath printed commons-text-1.12.0.jar and commons-lang3-3.14.0.jar. We read the No cached version available for offline mode part of the message in section 7.
7. Where do metadata and jars come from? — the repository chain and the cache
In section 5's stack, the metadata came out of CachingModuleComponentRepository's LocateInCacheRepositoryAccess, via RepositoryChainComponentMetaDataResolver. Let's follow that path.
The cache first, the repositories later
It's commonly said that if you list several repositories in repositories { }, they are searched in that order. The source is a bit more precise.
private RepositoryChainModuleResolution findBestMatch(List<ComponentMetaDataResolveState> resolveStates, RepositoryFailureCollector failures) {
LinkedList<ComponentMetaDataResolveState> queue = new LinkedList<>(resolveStates);
LinkedList<ComponentMetaDataResolveState> missing = new LinkedList<>();
// A first pass to do local resolves only
RepositoryChainModuleResolution best = findBestMatch(queue, failures, missing);
if (failures.hasFatalError()) {
return null;
}
if (best != null) {
return best;
}
// Nothing found locally - try a remote search for all resolve states that were not yet searched remotely
queue.addAll(missing);
missing.clear();
return findBestMatch(queue, failures, missing);
}
Two passes. The first pass looks only at "local" for every repository. If anything is found, that's the end. Only when nothing is found anywhere does the second pass look at remote. The ComponentMetaDataResolveState.resolve() attached to each repository calls local and remote separately (ComponentMetaDataResolveState.java lines 46–66). So Gradle doesn't send a network request to the first repository to get something that's in the second repository's cache.
"Local" here doesn't mean mavenLocal(); it means the dependency cache. Each repository is wrapped in a cache layer (CachingModuleComponentRepository), and that class's description (ivyservice/ivyresolve/CachingModuleComponentRepository.java lines 65–73) splits the two accesses. Local access takes things out of the cache (expiry is judged by the resolution strategy's cache policy); remote access asks the real repository and stores the result in the cache. Reading metadata remotely is done by ExternalResourceResolver, which tries its metadata sources (metadataSources) in order and uses the first that returns something (repositories/resolver/ExternalResourceResolver.java lines 246–258).
A Maven repository (including mavenCentral()) has a single default source, the POM (MavenMetadataSources.setDefaults() turns on only mavenPom(), DefaultMavenArtifactRepository.java lines 485–488), and that POM source is wrapped in a "redirecting" wrapper, RedirectingGradleMetadataModuleMetadataSource (DefaultMavenArtifactRepository.java lines 327–334). If the POM contains the comment do_not_remove: published-with-gradle-metadata (PomReader.java line 360), the result is marked "go to Gradle metadata", and the wrapper reads the .module file next to it and uses that instead (RedirectingGradleMetadataModuleMetadataSource.java lines 50–59). Libraries published with Gradle put this comment at the top of their POM, so Maven reads the POM and Gradle reads the .module.
The cache in the yellow box lets the next build and other projects reuse what was downloaded. The red box is a path that only exists with --offline.
--offline turns remote access into a failure
--offline does three things to dependency resolution. First, if the start parameter says offline, every repository is wrapped in an OfflineModuleComponentRepository (ivyservice/ivyresolve/StartParameterResolutionOverride.java lines 66–70). That wrapper leaves the original repository as it is but replaces only getRemoteAccess() with FailedRemoteAccess (lines 115–127). Local (cache) access is unchanged, and remote access returns a failure no matter what it's asked. Second, it switches the resolution strategy's cache policy to offline (ResolutionStrategyFactory.java lines 118–126), so cached results are used as they are, without checking whether they have expired (DefaultCacheExpirationControl.applyOfflineRule(), lines 166–172). Third, the repository transport layer gets an OfflineExternalResourceConnector instead of a network connector (RepositoryTransportFactory.java lines 124–126).
@Override
public void resolveComponentMetaData(ModuleComponentIdentifier moduleComponentIdentifier, ComponentOverrideMetadata requestMetaData, BuildableModuleComponentMetaDataResolveResult<ModuleComponentResolveMetadata> result) {
result.failed(new ModuleVersionResolveException(moduleComponentIdentifier, () -> String.format("No cached version of %s available for offline mode.", moduleComponentIdentifier.getDisplayName())));
}
StartParameterResolutionOverride.java lines 140–143
In Example 3, -Pmissing declares one more dependency that isn't in the cache, org.slf4j:slf4j-api:2.0.17 (the image only has 2.0.16).
* What went wrong:
Execution failed for task ':printClasspath' (registered in build file 'build.gradle.kts').
> Could not resolve all files for configuration ':compileClasspath'.
> Could not resolve org.slf4j:slf4j-api:2.0.17.
Required by:
root project 'offline-cache'
> No cached version of org.slf4j:slf4j-api:2.0.17 available for offline mode.
(I dropped the FAILURE: … line before it and the * Try: hints after it.) The last line of the message is exactly the string from the code above. This time even the metadata is missing, so it failed in graph resolution. Section 6's failure (Could not download commons-lang3-3.12.0.jar … No cached version available for offline mode) came from resolveArtifact() in the same class (lines 150–153) — a failure in file resolution.
Why this series' examples run without a network — the read-only cache
This series' examples run in a fresh container with a fresh ~/.gradle every time. They can still find commons-text with --offline because the image contains a read-only dependency cache, announced through the GRADLE_RO_DEP_CACHE environment variable (stage 3 of the Dockerfile in part 1). The place that reads this environment variable is the constructor of DefaultArtifactCaches (ivyservice/DefaultArtifactCaches.java lines 62–68).
The Shared read-only dependency cache is an incubating feature. line printed on the first run of every example is the incubatingFeatureUsed() call on line 64 (a notice that the feature is still experimental). This cache is combined with the writable cache in two stages. Taking the metadata cache as an example, TwoStageModuleMetadataCache.get() checks the writable cache (~/.gradle/caches/modules-2) first and the read-only cache only if that misses (ivyservice/modulecache/TwoStageModuleMetadataCache.java lines 36–43). Storing (store) only goes to the writable cache (lines 30–34 of the same file). Example 3's run.sh confirms it:
=== GRADLE_RO_DEP_CACHE ===
/opt/gradle-ro-cache
files-2.1
metadata-2.107
=== gradle printClasspath --info (selected lines) ===
Shared read-only dependency cache is an incubating feature.
The read-only dependency cache is enabled
The GRADLE_RO_DEP_CACHE environment variable was set to /opt/gradle-ro-cache
...
/opt/gradle-ro-cache/modules-2/files-2.1/org.apache.commons/commons-text/1.12.0/66aa90dc099701c4d3b14bd256c328f592ccf0d6/commons-text-1.12.0.jar
/opt/gradle-ro-cache/modules-2/files-2.1/com.google.guava/guava/33.3.1-jre/852f8b363da0111e819460021ca693cacca3e8db/guava-33.3.1-jre.jar
/opt/gradle-ro-cache/modules-2/files-2.1/org.apache.commons/commons-lang3/3.14.0/1ed471194b02f2c6cb734a0cd6f6f107c673afae/commons-lang3-3.14.0.jar
... (six more jars pulled in by guava)
=== the writable cache in ~/.gradle/caches/modules-2 ===
metadata-2.107
modules-2.lock
jar files: 0
(The ... in the middle replaces the metadata rule lines and stack from example 3 in section 5 and the other six guava jar lines.) Every jar on the compile classpath points inside /opt/gradle-ro-cache. Gradle doesn't copy them; it uses the files where they are. The writable cache has no jars at all — only the metadata index created during resolution (metadata-2.107) and a lock file. You can also see the shape of the path — files-2.1/<group>/<name>/<version>/<SHA-1>/<file>. Because the hash is part of the path, different files with the same name never mix. The numbers after modules, files and metadata are versions of the cache layout, defined by the CacheLayout enum (metadata changed to 107 in 8.11-rc-1, CacheLayout.java line 79).
I also looked at the metadata files in the same cache. This is why only guava showed a .module variant name (jreApiElements) in section 5.
com.google.guava/guava/33.3.1-jre/<sha1>/guava-33.3.1-jre.jar
com.google.guava/guava/33.3.1-jre/<sha1>/guava-33.3.1-jre.module
com.google.guava/guava/33.3.1-jre/<sha1>/guava-33.3.1-jre.pom
org.apache.commons/commons-text/1.12.0/<sha1>/commons-text-1.12.0.jar
org.apache.commons/commons-text/1.12.0/<sha1>/commons-text-1.12.0.pom
guava pom marker:
4: <!-- do_not_remove: published-with-gradle-metadata -->
commons-text pom marker count: 0
(The hash directory names were replaced with <sha1> when printing.) guava's POM has the marker on line 4, so its .module was fetched too; commons-text's POM has no marker, so there is only the POM.
Easy to get confused
--offlinedoesn't block the cache. It only blocks remote access, and whatever is in the cache is used even if it has expired (the cache policy above). If the cache has no version listing, a dynamic version (1.+) fails withNo cached version listing for … available for offline mode.(StartParameterResolutionOverride.javalines 135–138).mavenLocal()is not this "local".mavenLocal()is just another repository pointing at~/.m2/repository, and the cache layer and the two-pass lookup apply to it in exactly the same way.~/.gradle/caches/modules-2and~/.m2are separate things.
What this post leaves out
- Variant selection and attribute matching (compatibility and disambiguation rules):
platforms/software/dependency-management/…/internal/component/model/GraphVariantSelector.java,…/internal/component/resolution/failure/ - Artifact transforms (turning downloaded jars into another form):
…/api/internal/artifacts/transform/ - Capability conflicts:
…/resolveengine/graph/conflicts/DefaultCapabilitiesConflictHandler.java— the other half oftraverseGraph() - Platforms and BOMs (
platform(…), version alignment), virtual platforms:…/resolveengine/graph/builder/VirtualPlatformState.java - Dependency locking (
dependencyLocking) and verification (verification-metadata.xml):DependencyLockingGraphVisitorinResolutionExecutor.resolveGraph(),…/ivyservice/ivyresolve/verification/ - Cache periods for dynamic versions and changing modules (
cacheDynamicVersionsFor):…/ivyservice/resolutionstrategy/DefaultCachePolicy.java - How version catalogs (
libs.versions.toml) become accessors:platforms/software/dependency-management/…/catalog/ - The lightweight path that only computes a configuration's task dependencies when building the execution plan (
resolveBuildDependencies):DefaultConfigurationResolver.javalines 121–128 - Ivy repositories and
ivy.xml:…/ivyservice/ivyresolve/parser/IvyXmlModuleDescriptorParser.java
Summary
| Concept | One-line summary |
|---|---|
implementation(…) |
Generated accessor = dependencies.add("implementation", …). It just puts a Dependency object into a configuration |
Dependency object |
The parser turns string notation into a DefaultExternalModuleDependency (_Decorated). It holds only the version string |
| Configuration roles | Declare (DEPENDENCY_SCOPE), resolve (RESOLVABLE), consume (CONSUMABLE). implementation only holds; compileClasspath inherits and resolves |
| Lazy resolution | Once, the first time someone asks for the file list. Usually in CaptureMutableStateBeforeExecutionStep, where the execution engine fingerprints inputs |
| Locked after resolution | markAsObserved("resolved") when building the root variant — even inherited configurations can't be changed |
| Two steps | Graph resolution (Resolve dependencies of …, metadata only) and file resolution (Resolve files of configuration …, jars) |
DependencyGraphBuilder |
A loop that takes a node from the queue, collects its edges, picks versions, fetches metadata and attaches the edges |
| Node | Not a component but a variant of a component (NodeState). Variants are matched against the configuration's attributes |
| Version conflict | Each time requests pile up on a module: SelectorStateResolver.selectBest() → LatestModuleConflictResolver — the highest version |
strictly |
Rejects the complement of the version and makes edges further down ignore their versions — by ancestor |
force |
A substitution rule in the resolution strategy that rewrites every request for that module — forced |
| Repository lookup | The cache of every repository first (pass 1), remote only if nothing was found (pass 2). If the POM has the marker, go to the .module |
--offline |
Replaces remote access with FailedRemoteAccess and uses the cache regardless of expiry |
| Read-only cache | GRADLE_RO_DEP_CACHE. Checked after the writable cache; files are used where they are |
Wrapping up the series
With this post, all 12 parts listed on the part 1 map are done. Here is the whole series.
| Part | Post | One-line summary |
|---|---|---|
| 1 | How I Read It with AI, and the Map | Pinned a commit, built Gradle with Gradle in Docker, and drew the folder map and the main road of one gradle build |
| 2 | The Big Picture — Client and Daemon, Service Scopes, and the Three Build Phases | Four processes, the state hierarchy process > session > build tree > build > project with its 1:1 service scopes, and the state machine of the three phases |
| 3 | From the gradle Command to the Daemon — the Launcher, the Wrapper and the Daemon Protocol | A small client finds or starts a compatible daemon through registry.bin, and the request goes through the daemon's command chain |
| 4 | The Service Registry — the DI Container Inside Gradle | One registry per scope, lookups follow the parent, and abstract getters are filled in by _Decorated classes |
| 5 | The Build Lifecycle — Where Initialization, Configuration and Execution Split | Through the executor chain, the state transitions of DefaultBuildLifecycleController guard the three phases |
| 6 | Projects and Build Scripts — How build.gradle.kts Gets Run | The evaluator chain runs the script, and the Kotlin DSL splits off plugins {} first and compiles in stages |
| 7 | Applying Plugins — From the plugins {} Block to apply() | Plugin requests are found by resolvers, apply() is called, and plugins apply other plugins in a chain |
| 8 | Tasks and Lazy Configuration — DefaultTask, TaskContainer and Provider | register creates tasks only when needed, and a Provider carries the task that produces its value |
| 9 | The Task Graph and the Execution Plan — What, in What Order, How Many at Once | The execution plan orders the nodes, and worker leases decide how many can run at once |
| 10 | The Execution Engine and Incremental Builds — How UP-TO-DATE Is Decided | A task is wrapped as a unit of work and run through a step chain that compares input and output fingerprints with the last run |
| 11 | The Build Cache and the Configuration Cache — Two Ways to Reuse Work | Task outputs are stored under a cache key, and the configuration phase's result is stored as a whole work graph, to be reused |
| 12 | Dependency Resolution — From One implementation Line to Jar Files, and Wrapping Up the Series (this post) | Declarations wait in configurations; when inputs are fingerprinted, the graph is built, the highest version is chosen and the jars are found in the cache |
The road of one gradle build, once more
Part 1 drew the main road of one gradle build as a single line, and part 2 unfolded it again as a map of six areas, A to F. As a last step, let's lay all twelve parts over that map.
This post was the green box at the bottom right. The two arrows coming into it are what the dotted lines on the part 2 map meant.
The gradle in your terminal doesn't build anything. A small client finds a daemon and sends it a request (part 3), and for each request the daemon opens a new session and build tree, with a service registry on each of those layers (parts 2 and 4). The lifecycle controller moves through its states one by one (part 5), running settings and build scripts (part 6); the script's plugins {} applies plugins (part 7), and plugins only register tasks (part 8). implementation(…) too is only put into a configuration at that point (this post). Once an execution plan is built from the requested tasks (part 9), worker threads pick up nodes and feed them to the execution engine; while fingerprinting inputs — the moment dependencies are resolved (this post) — the engine finishes with UP-TO-DATE if nothing changed since last time (part 10), with FROM-CACHE if the result is in the cache (part 11), and otherwise calls the task action.
Three ideas kept coming back over the twelve parts.
- Postponing. Tasks are only registered and created when needed (part 8), a
Provideris computed when its value is taken out (part 8), and dependencies are resolved when the file list is needed (this post). Keeping the configuration phase short is what makes having a daemon worthwhile. And the place where everything postponed gets looked at all at once was part 10's input-fingerprinting step. - Reusing. The daemon reuses the JVM and class loaders (part 3), execution history reuses the last fingerprints (part 10), the build cache and configuration cache reuse results (part 11), and the dependency cache reuses downloaded files (this post). Reusing requires deciding "is it the same?", and a large part of Gradle's source goes into that decision (fingerprints, cache keys, compatibility checks).
- Layers and chains. State is stacked in layers (process > session > build tree > build > project) and processing steps in chains (the daemon's command chain, the executor chain, the evaluator chain, the execution engine's step chain). Even for a Gradle feature you've never looked at, asking "which layer's service is this?" and "which link of which chain?" gives you a good guess about where to start reading.
Compared with the earlier series
This blog has already read Spring (27 parts), Apache httpd (16 parts) and Tomcat (16 parts), and the nginx and MariaDB series were written at the same time. Gradle is a build tool, but opening its source revealed a lot in common with those series.
- The service registry resembles Spring's IoC container. Parent and child containers, constructor injection, even generating a subclass and slotting it in like a proxy (part 4, and Spring part 3 and part 5). The difference is that Spring keeps one container running for a long time, while Gradle has one per scope and starts the lower layers anew for each build. This post's "pick one among several candidates" also has the same shape as choosing an
@Autowiredcandidate in Spring part 5, but the criterion is not@Primaryor the name — it's which version is higher. - Step chains are the same idea as Tomcat's valves and httpd's hooks. The way an execution engine step wraps and calls the next step is the same as
getNext().invoke()in Tomcat part 8, and plugging other people's code into predefined slots is the same as the hooks in httpd part 6. - The daemon is a server process. It lives long, takes requests, processes them and waits for the next one, and if it's busy a new process takes the request instead (part 3). Where httpd's MPM dealt with "how to split work across processes and threads", Gradle keeps it simple: "one daemon = one build at a time". Separating plugins and scripts with class loaders resembles the web application class loaders of Tomcat part 4.
And most of these series' sources are built with Gradle. The Spring Framework that the Spring series read (commit f447f3c310) is built with a Gradle 9.8.0 wrapper (that commit's gradle/wrapper/gradle-wrapper.properties), and this blog itself is built with a Gradle 9.5.1 wrapper and the Kotlin DSL.
What to read next
The next series is Digging into Kafka's Source. Kafka is built with Gradle too. The wrapper at the commit the Kafka series reads (ab1e816411) is Gradle 9.8.2, and the root build.gradle contains exactly the force we read in section 6:
configurations.all {
// zinc is the Scala incremental compiler, it has a configuration for its own dependencies
// that are unrelated to the project dependencies, we should not change them
if (name != "zinc") {
resolutionStrategy {
force(
// be explicit about the javassist dependency version instead of relying on the transitive version
libs.javassist,
// ensure we have a single version in the classpath despite transitive dependencies
libs.scalaLibrary,
libs.scalaReflect,
libs.jacksonAnnotations
)
}
}
}
build.gradle lines 189–204 (Apache Kafka, commit ab1e816411)
Because it sits inside the allprojects block, it puts a ModuleForcingResolveRule on every configuration (configurations.all) of every project except zinc so that the Scala libraries and the Jackson annotations enter the classpath in a single version. "A single version despite transitive dependencies", as the comment puts it, is exactly an attempt to override section 6's default rule (the highest version). The Kafka series also starts by running this Gradle build in Docker. Kafka itself — the road one record travels from send() through disk and replication to poll() — is covered in that series.
- Kafka part 1: How I Read It with AI, and the Map
- Gradle part 1: How I Read It with AI, and the Map
- Gradle part 2: The Big Picture — Client and Daemon, Service Scopes, and the Three Build Phases
- Gradle part 10: The Execution Engine and Incremental Builds — How UP-TO-DATE Is Decided
- Tomcat part 16: Embedded Tomcat, JMX and Logging — and Wrapping Up the Series