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.
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?
The dev.academy.flavor consumer attribute changed; coordinates and the project dependency stayed the same.
Why is the legacy metadata rule scoped with
withModule?
Only the synthetic legacy module was proven to lack the capability. A global rule would silently rewrite unrelated components.
What evidence distinguishes the consumer request from producer availability?
resolvableConfigurations shows consumer attributes; outgoingVariants shows producer attributes/capabilities/artifacts.
Why is the capability conflict better than excluding loggerA?
The conflict models that two components provide the same feature. Selection then records policy explicitly instead of erasing one dependency path.
What metadata did the synthetic repository intentionally omit?
Gradle Module Metadata. It contains only a Maven POM and JAR, so Gradle derives variants from POM semantics.
What must you reconsider before publishing a custom Gradle-only flavor for Maven consumers?
Whether the semantics need separate Maven coordinates/classifiers/artifacts or another interoperable representation, because Maven POM cannot express arbitrary Gradle attributes/capabilities.
Official references and version notes
- Gradle 9.7.1 Release Notes — pinned Gradle baseline; released August 19, 2026.
- Creating Dependency Configurations — declarable, resolvable, consumable roles and role flags. The role-specific factory methods are currently incubating.
- Dependency Resolution and Variant Selection and Attribute Matching.
- Variants and Attributes — standard/custom attributes, compatibility and disambiguation.
- Capabilities — conflicting providers and capability resolution.
- Modifying Dependency Metadata — narrowly scoped, cacheable component metadata rules.
- Gradle Module Metadata and Metadata Formats — GMM, Maven POM, Ivy, and interoperability limits.
-
Viewing Dependencies
—
dependenciesanddependencyInsight;--all-variantsis currently incubating. - Compatibility Matrix — Gradle currently requires JVM 17 through 26 to execute.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.