Chapter 19Lesson 02~235 minutes

Gradle Configurations, Dependency Resolution, Variants, Attributes, Capabilities, and Metadata Rules: Guided Hands-On Workflow and Core Operations

Build a disposable local dependency-resolution lab that inspects configuration roles, selects one of two project variants by attribute, enriches a POM-only module with a narrow metadata rule, and diagnoses a capability conflict with evidence.

dependencyInsightOutgoing variantsPOM-only metadataMetadata ruleLocal lab

Build a disposable local dependency-resolution lab that inspects configuration roles, selects one of two project variants by attribute, enriches a POM-only module with a narrow metadata rule, and diagnoses a capability conflict with evidence.

Learning objectives

  • Create a disposable multi-project lab under a verified Gradle 9.7.1 Wrapper and isolated Gradle User Home.
  • Inspect resolvable and consumable configurations before creating custom variants.
  • Create two producer runtime variants distinguished by a custom typed attribute and select them from a consumer configuration.
  • Use dependencyInsight/outgoingVariants/resolvableConfigurations to prove the selection path.
  • Create a synthetic POM-only module and enrich it with a narrowly scoped cacheable component metadata rule.
  • Create and repair a capability conflict without arbitrary dependency exclusion.

1. Preflight: use a trusted Wrapper and disposable state

Create this lab by copying the verified Wrapper files from the Chapter 15/16/17 lab or another reviewed Gradle 9.7.1 project. Do not bootstrap an unknown wrapper from an arbitrary gradle executable. The commands below assume the Wrapper is already present.

mkdir gradle-resolution-lab
cd gradle-resolution-lab
# Copy reviewed gradlew, gradlew.bat and gradle/wrapper/* here first.
export GRADLE_USER_HOME="$PWD/.gradle-user-home"
./gradlew --version
java -version

Expected identity: Gradle 9.7.1 and a supported JVM; this course uses JDK 21. All downloaded/cache state stays inside the disposable lab.

2. Create a small producer/consumer build

The first model has a producer with two consumable variants and a consumer with one resolvable configuration. It does not need external dependencies.

// settings.gradle.kts
rootProject.name = "resolution-lab"
include("producer", "consumer", "loggerA", "loggerB")
mkdir -p producer/src/standard producer/src/instrumented consumer loggerA loggerB
printf 'mode=standard\n' > producer/src/standard/mode.txt
printf 'mode=instrumented\n' > producer/src/instrumented/mode.txt

3. Model two consumable producer variants

plugins { base }

group = "dev.academy"
version = "1.0.0"

val flavor = Attribute.of("dev.academy.flavor", String::class.java)

val standardJar by tasks.registering(Jar::class) {
    archiveBaseName.set("academy-runtime")
    archiveClassifier.set("standard")
    from("src/standard")
}
val instrumentedJar by tasks.registering(Jar::class) {
    archiveBaseName.set("academy-runtime")
    archiveClassifier.set("instrumented")
    from("src/instrumented")
}

fun Configuration.runtimeVariant(flavorValue: String, artifactTask: TaskProvider<Jar>) {
    isCanBeDeclared = false
    isCanBeResolved = false
    isCanBeConsumed = true
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class.java, Usage.JAVA_RUNTIME))
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category::class.java, Category.LIBRARY))
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, LibraryElements.JAR))
        attribute(flavor, flavorValue)
    }
    outgoing.artifact(artifactTask)
}

configurations.create("standardElements") {
    runtimeVariant("standard", standardJar)
}
configurations.create("instrumentedElements") {
    runtimeVariant("instrumented", instrumentedJar)
}

The two variants have the same usage/category/library-elements protocol and differ only in dev.academy.flavor. Their names help humans read reports; the custom attribute is what makes them semantically distinct to resolution.

Run read-only producer inspection:

./gradlew :producer:outgoingVariants
./gradlew :producer:outgoingVariants --variant standardElements
./gradlew :producer:outgoingVariants --variant instrumentedElements

Expected evidence: both variants are consumable, each has the standard JVM-ish artifact attributes plus one distinct flavor value, and each points at its own classified JAR.

4. Model a resolvable consumer request lazily

plugins { base }

val flavor = Attribute.of("dev.academy.flavor", String::class.java)
val requestedFlavor = providers.gradleProperty("academyFlavor").orElse("standard")

val academyRuntime = configurations.create("academyRuntime") {
    isCanBeDeclared = false
    isCanBeResolved = true
    isCanBeConsumed = false
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class.java, Usage.JAVA_RUNTIME))
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category::class.java, Category.LIBRARY))
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, LibraryElements.JAR))
        attributeProvider(flavor, requestedFlavor)
    }
}

dependencies {
    add("academyRuntime", project(":producer"))
}

tasks.register("showSelectedArtifact") {
    inputs.files(academyRuntime)
    doLast {
        academyRuntime.files.sortedBy { it.name }.forEach { println(it.name) }
    }
}

attributeProvider() carries the Gradle property into the attribute container without an eager get() in the script. The configuration has exactly one role: resolution.

./gradlew :consumer:resolvableConfigurations --configuration academyRuntime
./gradlew :consumer:showSelectedArtifact
./gradlew :consumer:showSelectedArtifact -PacademyFlavor=instrumented

The first run should print a -standard.jar; the second should print -instrumented.jar. The only changed input to selection is the requested flavor attribute.

5. Prove why the variant was selected

Capture both ends of the protocol and one dependency edge:

./gradlew :consumer:resolvableConfigurations --configuration academyRuntime > consumer-request.txt
./gradlew :producer:outgoingVariants > producer-variants.txt
./gradlew :consumer:dependencyInsight   --configuration academyRuntime   --dependency producer > selected-standard.txt
./gradlew :consumer:dependencyInsight   --configuration academyRuntime   --dependency producer   --all-variants > all-variants.txt

--all-variants is useful for a lab but currently incubating. Treat the human-readable report as evidence, not a frozen machine interface.

6. Create a synthetic POM-only component locally

To learn metadata repair without relying on the network, create one harmless Maven-style module that deliberately has no Gradle .module metadata:

mkdir -p local-repo/dev/academy/legacy-lib/1.0 tmp-legacy
printf 'legacy payload\n' > tmp-legacy/payload.txt
jar --create --file local-repo/dev/academy/legacy-lib/1.0/legacy-lib-1.0.jar -C tmp-legacy payload.txt
cat > local-repo/dev/academy/legacy-lib/1.0/legacy-lib-1.0.pom <<'EOF'
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>dev.academy</groupId>
  <artifactId>legacy-lib</artifactId>
  <version>1.0</version>
</project>
EOF
rm -rf tmp-legacy

The file repository is synthetic publication input. There are no credentials and no production coordinates.

7. Add one narrow, cacheable metadata rule

Append the following to consumer/build.gradle.kts. The rule is scoped to exactly one module and only adds missing semantic information:

@CacheableRule
abstract class LegacyCapabilityRule : ComponentMetadataRule {
    override fun execute(context: ComponentMetadataContext) {
        context.details.allVariants {
            withCapabilities {
                addCapability("dev.academy", "legacy-contract", context.details.id.version)
            }
        }
    }
}

repositories {
    maven {
        url = uri(rootProject.file("local-repo"))
        metadataSources {
            mavenPom()
            artifact()
        }
    }
}

val legacyRuntime = configurations.create("legacyRuntime") {
    isCanBeDeclared = false
    isCanBeResolved = true
    isCanBeConsumed = false
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class.java, Usage.JAVA_RUNTIME))
    }
}

dependencies {
    components {
        withModule<LegacyCapabilityRule>("dev.academy:legacy-lib")
    }
    add("legacyRuntime", "dev.academy:legacy-lib:1.0")
}
./gradlew :consumer:dependencies --configuration legacyRuntime
./gradlew :consumer:dependencyInsight   --configuration legacyRuntime   --dependency legacy-lib   --all-variants

Because the repository is configured with mavenPom(), Gradle derives variants from the POM. The rule then enriches those derived variants with the explicit dev.academy:legacy-contract capability. This demonstrates where repair occurs: after metadata is read, before selection is finalized.

8. Create two local providers of one capability

Use different project coordinates but one shared capability. Once explicit capabilities are added, also declare each component's implicit coordinate capability explicitly.

plugins { `java-library` }

group = "dev.academy"
version = "1.0.0"

listOf("apiElements", "runtimeElements").forEach { name ->
    configurations.named(name) {
        outgoing.capability("dev.academy:logger-a:1.0.0")
        outgoing.capability("dev.academy:logging-binding:1.0")
    }
}

Save that as loggerA/build.gradle.kts. For loggerB, use:

plugins { `java-library` }

group = "dev.academy"
version = "2.0.0"

listOf("apiElements", "runtimeElements").forEach { name ->
    configurations.named(name) {
        outgoing.capability("dev.academy:logger-b:2.0.0")
        outgoing.capability("dev.academy:logging-binding:2.0")
    }
}

9. Observe and repair a capability conflict

Append two dependencies to the consumer:

val loggingRuntime = configurations.create("loggingRuntime") {
    isCanBeDeclared = false
    isCanBeResolved = true
    isCanBeConsumed = false
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class.java, Usage.JAVA_RUNTIME))
    }
}
dependencies {
    add("loggingRuntime", project(":loggerA"))
    add("loggingRuntime", project(":loggerB"))
}

tasks.register("resolveLogging") {
    inputs.files(loggingRuntime)
    doLast {
        println(loggingRuntime.files.joinToString("\n") { it.name })
    }
}
set +e
./gradlew :consumer:resolveLogging > capability-conflict.txt 2>&1
status=$?
set -e
printf 'conflict status=%s\n' "$status"

Expected: resolution fails because both projects advertise dev.academy:logging-binding. Preserve the message. Then make the consumer's policy explicit:

configurations.named("loggingRuntime") {
    resolutionStrategy.capabilitiesResolution
        .withCapability("dev.academy:logging-binding") {
            selectHighestVersion()
            because("the lab prefers the reviewed provider with capability version 2.0")
        }
}
./gradlew :consumer:resolveLogging
./gradlew :consumer:dependencyInsight --configuration loggingRuntime --dependency logger

The repair selects between competing capability providers. It does not pretend one dependency edge never existed.

10. Compare rich Gradle metadata with POM-derived behavior

The custom producer variants are visible directly in a multi-project build because Gradle has the complete producer model. The synthetic legacy-lib supplies only a POM, so Gradle derives conventional variants and your scoped rule supplies one missing capability.

For a standard java-library published by Gradle to a Maven repository, inspect both generated files: the POM preserves Maven-compatible scopes, while the .module file preserves Gradle's richer variant attributes/capabilities. A Maven consumer will not understand Gradle-only custom attributes/capabilities. If cross-build interoperability requires those semantics, redesign them into a representation the target ecosystem can actually consume.

11. Challenge: choose the smallest correct control

Requirement: “The consumer should choose the instrumented producer only in a diagnostic job; ordinary builds should remain standard.” Which control is smallest?

Answer to reason toward: keep both producer variants stable and change the consumer's requested flavor attribute (for example via -PacademyFlavor=instrumented). Do not fork coordinates, globally rewrite metadata, exclude artifacts, or make the producer inspect CI environment variables.

12. Cleanup

./gradlew --stop || true
cd ..
rm -rf gradle-resolution-lab

This deletes only the disposable lab, including its project-local Gradle User Home and synthetic repositories. It does not touch normal ~/.gradle state.

Knowledge check

What single input changed between standard and instrumented project-variant resolution?

Why is the legacy metadata rule scoped with withModule?

What evidence distinguishes the consumer request from producer availability?

Why is the capability conflict better than excluding loggerA?

What metadata did the synthetic repository intentionally omit?

What must you reconsider before publishing a custom Gradle-only flavor for Maven consumers?

Official references and version notes

Version snapshot: Generated August 24, 2026 with Gradle 9.7.1, JDK 21 as the Gradle runtime, Java 17 as the course target, only core Gradle APIs, a verified project Wrapper, and project-local Gradle User Homes. Re-check incubating APIs before standardizing them.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.