namalzi

← All posts

Digging into Gradle's Source 12: Dependency Resolution — From One implementation Line to Jar Files, and Wrapping Up the Series

Gradle · Published

What does the single implementation line in build.gradle.kts become in the source? This post follows the DependencyHandler.add() call behind the generated accessor, configurations whose roles are split into declare, resolve and consume, resolution that waits until someone asks for the file list, the loop in which DependencyGraphBuilder grows the graph, the LatestModuleConflictResolver that picks the higher of two versions of the same library and the places where strictly and force change that result, and the two-pass lookup that checks the cache before the repositories, together with --offline and the read-only cache — all checked against the source at a fixed commit and with Docker examples. It then wraps up the 12-part series.

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.

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.

The big picture of dependency resolution. In the configuration phase, the implementation line in build.gradle.kts goes through ① DefaultDependencyHandler.add() and becomes a Dependency object stored in the implementation configuration. Configurations form a tree: implementation and compileOnly feed compileClasspath, implementation and runtimeOnly feed runtimeClasspath. Nothing is resolved up to here. When someone asks for the file list (usually while fingerprinting compileJava's inputs in the execution phase), ② graph resolution (Resolve dependencies of :compileClasspath, DependencyGraphBuilder and conflict resolution) happens while reading metadata (POM, .module, repositories and the dependency cache), and ③ file resolution (Resolve files of configuration) finds the jars of the chosen variants and produces the list of jar files that becomes javac's -classpath. 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.

  1. Declare (section 2) — while the build script runs in the configuration phase, a Dependency object is created and put into a configuration. That's all. Neither the network nor the disk is touched.
  2. 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.
  3. 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)

platforms/core-configuration/kotlin-dsl/src/main/kotlin/org/gradle/kotlin/dsl/accessors/AccessorFragments.kt lines 229–230

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);
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/dsl/dependencies/DefaultDependencyHandler.java lines 115–119

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.

How one implementation line becomes a Dependency object. implementation("org.apache.commons:commons-text:1.12.0") in build.gradle.kts is a generated accessor (AccessorFragments.kt lines 229–230), so it is the same as dependencies.add("implementation", …). DefaultDependencyHandler.add() finds the configuration with configurationContainer.getByName("implementation") (failing here if it doesn't exist), and doAdd() branches on the kind of notation. Passing a Configuration throws a GradleException (not allowed since Gradle 8.0). A Provider or ProviderConvertible (a version catalog libs.xxx) goes through doAddProvider() into addLater(), and add() returns null. Any other notation, such as a string, goes to doAddRegularDependency(), where create() passes through DefaultDependencyFactory and DependencyNotationParser to DependencyStringNotationConverter, which splits on colons and reads the version once more with StrictVersionParser. The result, DefaultExternalModuleDependency_Decorated (group=org.apache.commons, name=commons-text, version=1.12.0), is added to the implementation configuration's dependency set. 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

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.

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);

subprojects/core/src/main/java/org/gradle/api/internal/artifacts/configurations/ConfigurationRoles.java lines 50–80

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);
        });

platforms/jvm/plugins-java-base/src/main/java/org/gradle/api/plugins/JavaBasePlugin.java lines 293–314

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.

The configuration tree the java plugin builds. The left group holds the declare-only configurations (DEPENDENCY_SCOPE, declare=true resolve=false consume=false) implementation, compileOnly and runtimeOnly. The top right group holds the resolvable configurations (RESOLVABLE) compileClasspath (compileJava's classpath) and runtimeClasspath (classpath for test and run); the middle right group holds the consumable configurations (CONSUMABLE) apiElements and runtimeElements. compileOnly and implementation lead to compileClasspath; implementation and runtimeOnly lead to runtimeClasspath and runtimeElements. Resolving implementation throws a canBeResolved=false exception (dotted arrow). 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

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:

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).

The call order in which resolution happens. ① CaptureMutableStateBeforeExecutionStep visits compileJava's classpath input to fingerprint input files, and ② that calls visitContents() on DefaultConfiguration (compileClasspath). ③ resolveGraphIfRequired() reuses the result if already resolved; otherwise ④ it opens the build operation Resolve dependencies of :compileClasspath. ⑤ After the beforeResolve listeners it calls DefaultConfigurationResolver.resolveGraph(this), and ⑥ the resolver builds the root variant and locks the configuration as observed. ⑦ ResolutionExecutor.resolveGraph(params, repository list), ⑧ via DependencyGraphResolver, DependencyGraphBuilder.resolve() completes the graph with traverseGraph, and ⑨ hands nodes and edges to the visitors. ⑩ ResolverResults come back, ⑪ file resolution (Resolve files of configuration) runs, and ⑫ the jar paths are fingerprinted. ⑥ 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

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

How the graph classes relate. ResolveState (queue, module table, conflict handlers; pop(), getModule(id)) has one ModuleResolveState per module (one group:name, selectors, maybeUpdateSelection()). A ModuleResolveState has several collected requests, SelectorState (one request — a version constraint, resolve()), and several version candidates, ComponentState (one group:name:version, evict()). A ComponentState has several chosen variants, NodeState (one variant of a component, visitOutgoingDependenciesAndCollectEdges()); a NodeState has several outgoing edges, EdgeState (computeSelector()), and an EdgeState points at the request SelectorState. ModuleResolveState uses SelectorStateResolver.selectBest() to pick the version, which uses LatestModuleConflictResolver.select() on conflict. 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();
                }
            }
        }
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/builder/DependencyGraphBuilder.java lines 172–208

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).

  1. performSelectionSerially() — for each edge, (re)select the version of the target module. Section 6's conflict resolution happens here.
  2. 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).
  3. 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

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);
        }
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/builder/ModuleResolveState.java lines 475–500

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;
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/selectors/SelectorStateResolver.java lines 226–238

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;
        }
        // ...

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/LatestModuleConflictResolver.java lines 41–60

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);
        // ...

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolveengine/graph/builder/EdgeState.java lines 145–148

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);

            }
        }
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/resolutionstrategy/ModuleForcingResolveRule.java lines 47–61

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:

Two requests for the same module and three outcomes. The root of compileClasspath depends on commons-text:1.12.0 and on commons-lang3:3.12.0 (request A, declared directly), and commons-text's POM requests commons-lang3:3.14.0 (request B). Both requests are collected in ModuleResolveState (org.apache.commons:commons-lang3). By default, SelectorStateResolver uses LatestModuleConflictResolver to pick the higher one, 3.14.0, with the reason By conflict resolution. With strictly 3.12.0, EdgeState.computeSelector() ignores B's version, giving 3.12.0 with the reason by ancestor. With force 3.12.0, ModuleForcingResolveRule rewrites both A and B to 3.12.0, giving 3.12.0 with the reason forced. 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);
    }

platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/ivyservice/ivyresolve/RepositoryChainComponentMetaDataResolver.java lines 136–154

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 path for finding metadata. When the graph asks for the metadata of commons-text:1.12.0, RepositoryChainComponentMetaDataResolver creates one ComponentMetaDataResolveState per repository. In pass 1, local access for every repository (CachingModuleComponentRepository's LocateInCacheRepositoryAccess) checks the dependency cache (~/.gradle/caches/modules-2 first, then GRADLE_RO_DEP_CACHE). If nothing is found, in pass 2 remote access (ResolveAndCacheRepositoryAccess) for the repositories that missed uses ExternalResourceResolver to try metadata sources in order. It reads the POM, and if the POM carries the published-with-gradle-metadata marker, it reads the .module (Gradle Module Metadata — variants and attributes). With --offline, remote access is replaced by FailedRemoteAccess and fails with No cached version … available for offline mode. 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

What this post leaves out

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.

The road of the whole series. The client from gradle build, gradlew or an IDE reaches the daemon's command chain over a socket (part 3) and enters the build lifecycle (DefaultBuildTreeLifecycleController and DefaultBuildLifecycleController, part 5). The service registry and scopes (Global → UserHome → BuildSession → BuildTree → Build → Project, part 4) supply services. Through the configuration phase (build scripts part 6 → plugins part 7 → tasks and Providers part 8), the execution plan and worker threads (part 9) and the execution engine step chain and UP-TO-DATE (part 10), task actions (javac, test, jar) run. The execution engine exchanges data with the build cache and configuration cache (part 11). Dependency resolution (configuration → graph → jars, part 12) is declared with implementation in the configuration phase and resolved when the execution engine fingerprints inputs. 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.

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.

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.

More posts