The output of the graph resolution phase is a fully resolved dependency graph, which is used as the input to the artifact resolution phase.

The ResolutionResult API provides access to the resolved dependency graph without triggering artifact resolution. This API presents the resolved dependency graph, where each node in the graph is a variant of a component.

Raw access to the dependency graph can be useful for a number of use cases:

  • Visualizing the dependency graph, for example generating a .dot file for Graphviz.

  • Exposing diagnostics about a given resolution, similar to the dependencies or dependencyInsight tasks.

  • Resolving a subset of the artifacts for a dependency graph when used in conjunction with the ArtifactView API.

Consider the following function that traverses a dependency graph, starting from the root node. Callbacks are notified for each node and edge in the graph. This function can be used as a base for any use case that requires traversing a dependency graph:

build.gradle.kts
fun traverseGraph(
    rootComponent: ResolvedComponentResult,
    rootVariant: ResolvedVariantResult,
    nodeCallback: (ResolvedVariantResult) -> Unit,
    edgeCallback: (ResolvedVariantResult, ResolvedVariantResult) -> Unit
) {
    val seen = mutableSetOf<ResolvedVariantResult>(rootVariant)
    nodeCallback(rootVariant)

    val queue = ArrayDeque(listOf(rootVariant to rootComponent))
    while (queue.isNotEmpty()) {
        val (variant, component) = queue.removeFirst()

        // Traverse this variant's dependencies
        component.getDependenciesForVariant(variant).forEach { dependency ->
            val resolved = when (dependency) {
                is ResolvedDependencyResult -> dependency
                is UnresolvedDependencyResult -> throw dependency.failure
                else -> throw AssertionError("Unknown dependency type: $dependency")
            }
            if (!resolved.isConstraint) {
                val toVariant = resolved.resolvedVariant

                if (seen.add(toVariant)) {
                    nodeCallback(toVariant)
                    queue.addLast(toVariant to resolved.selected)
                }

                edgeCallback(variant, toVariant)
            }
        }
    }
}
build.gradle
void traverseGraph(
    ResolvedComponentResult rootComponent,
    ResolvedVariantResult rootVariant,
    Consumer<ResolvedVariantResult> nodeCallback,
    BiConsumer<ResolvedVariantResult, ResolvedVariantResult> edgeCallback
) {
    Set<ResolvedVariantResult> seen = new HashSet<>()
    seen.add(rootVariant)
    nodeCallback(rootVariant)

    def queue = new ArrayDeque<Tuple2<ResolvedVariantResult, ResolvedComponentResult>>()
    queue.add(new Tuple2(rootVariant, rootComponent))
    while (!queue.isEmpty()) {
        def entry = queue.removeFirst()
        def variant = entry.v1
        def component = entry.v2

        // Traverse this variant's dependencies
        component.getDependenciesForVariant(variant).each { dependency ->
            if (dependency instanceof UnresolvedDependencyResult) {
                throw dependency.failure
            }
            if ((!dependency instanceof ResolvedDependencyResult)) {
                throw new RuntimeException("Unknown dependency type: $dependency")
            }

            def resolved = dependency as ResolvedDependencyResult
            if (!dependency.constraint) {
                def toVariant = resolved.resolvedVariant

                if (seen.add(toVariant)) {
                    nodeCallback(toVariant)
                    queue.add(new Tuple2(toVariant, resolved.selected))
                }

                edgeCallback(variant, toVariant)
            }
        }
    }
}

This function starts at the root variant, and performs a breadth-first traversal of the graph. The ResolutionResult API is lenient, so it is important to check whether a visited edge is unresolved (failed) or resolved. With this function, the node callback is always called before the edge callback for any given node.

Below, we leverage the above traversal function to transform a dependency graph into a .dot file for visualization:

build.gradle.kts
abstract class GenerateDot : DefaultTask() {

    @get:Input
    abstract val rootComponent: Property<ResolvedComponentResult>

    @get:Input
    abstract val rootVariant: Property<ResolvedVariantResult>

    @TaskAction
    fun traverse() {
        println("digraph {")
        traverseGraph(
            rootComponent.get(),
            rootVariant.get(),
            { node -> println("    ${toNodeId(node)} [shape=box]") },
            { from, to -> println("    ${toNodeId(from)} -> ${toNodeId(to)}") }
        )
        println("}")
    }

    fun toNodeId(variant: ResolvedVariantResult): String {
        return "\"${variant.owner.displayName}:${variant.displayName}\""
    }
}
build.gradle
abstract class GenerateDot extends DefaultTask {

    @Input
    abstract Property<ResolvedComponentResult> getRootComponent()

    @Input
    abstract Property<ResolvedVariantResult> getRootVariant()

    @TaskAction
    void traverse() {
        println("digraph {")
        traverseGraph(
            rootComponent.get(),
            rootVariant.get(),
            node -> { println("    ${toNodeId(node)} [shape=box]") },
            (from, to) -> { println("    ${toNodeId(from)} -> ${toNodeId(to)}") }
        )
        println("}")
    }

    String toNodeId(ResolvedVariantResult variant) {
        return "\"${variant.owner.displayName}:${variant.displayName}\""
    }
}
A proper implementation would not use println but would write to an output file. For more details on declaring task inputs and outputs, see the Writing Tasks section.

When we register the task, we use the ResolutionResult API to access the root component and root variant of the runtimeClasspath configuration:

build.gradle.kts
tasks.register<GenerateDot>("generateDot") {
    rootComponent = runtimeClasspath.flatMap {
        it.incoming.resolutionResult.rootComponent
    }
    rootVariant = runtimeClasspath.flatMap {
        it.incoming.resolutionResult.rootVariant
    }
}
build.gradle
tasks.register("generateDot", GenerateDot) {
    rootComponent = configurations.runtimeClasspath.incoming.resolutionResult.rootComponent
    rootVariant = configurations.runtimeClasspath.incoming.resolutionResult.rootVariant
}
This example uses incubating APIs.

Running this task, we get the following output:

digraph {
    "root project ::runtimeClasspath" [shape=box]
    "com.google.guava:guava:33.2.1-jre:jreRuntimeElements" [shape=box]
    "root project ::runtimeClasspath" -> "com.google.guava:guava:33.2.1-jre:jreRuntimeElements"
    "com.google.guava:failureaccess:1.0.2:runtime" [shape=box]
    "com.google.guava:guava:33.2.1-jre:jreRuntimeElements" -> "com.google.guava:failureaccess:1.0.2:runtime"
    "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava:runtime" [shape=box]
    "com.google.guava:guava:33.2.1-jre:jreRuntimeElements" -> "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava:runtime"
    "com.google.code.findbugs:jsr305:3.0.2:runtime" [shape=box]
    "com.google.guava:guava:33.2.1-jre:jreRuntimeElements" -> "com.google.code.findbugs:jsr305:3.0.2:runtime"
    "org.checkerframework:checker-qual:3.42.0:runtimeElements" [shape=box]
    "com.google.guava:guava:33.2.1-jre:jreRuntimeElements" -> "org.checkerframework:checker-qual:3.42.0:runtimeElements"
    "com.google.errorprone:error_prone_annotations:2.26.1:runtime" [shape=box]
    "com.google.guava:guava:33.2.1-jre:jreRuntimeElements" -> "com.google.errorprone:error_prone_annotations:2.26.1:runtime"
}

Compare this to the output of the dependencies task:

runtimeClasspath
\--- com.google.guava:guava:33.2.1-jre
     +--- com.google.guava:failureaccess:1.0.2
     +--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
     +--- com.google.code.findbugs:jsr305:3.0.2
     +--- org.checkerframework:checker-qual:3.42.0
     \--- com.google.errorprone:error_prone_annotations:2.26.1

Notice how the graph is the same for both representations.