Gradle Variant-Aware Resolution & Module Metadata

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon and the compiler both ran on JDK 26; no toolchain was involved). Every example was run through the wrapper in scratch copies of a small tidepool build, once with Kotlin DSL build files and once with Groovy DSL build files, and the output is the same in both apart from the order of unrelated tasks, which Gradle does not fix. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The commands use --console=plain and -q unless a listing shows the task lines. In the listings the "Consider enabling configuration cache" hint, the link to the problems report, the Try: hints of failures, the lines of the buildSrc build and the daemon start-up line are left out, and the scratch directory is shown as /work/tidepool. Where a listing is shortened the shortening is marked on its own line with an ellipsis. The report tasks and the data tasks below were run with --configuration-cache and are compatible with it; the failing resolutions are shown without it, because with it a failed resolution inside a task is wrapped in a serialization error that hides the real message. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

The Java plugins, dependency declaration and publishing are covered elsewhere and are only used here as a means to show variants: Declaring Dependencies & Configurations covers the configuration roles, Dependency Resolution, Conflicts & Capabilities covers conflict resolution and capability conflicts, and Publishing covers publications and repositories.

From scopes to variants

Maven describes a component with one POM: a list of dependencies, each in a scope, plus optional classifiers on the artifacts. Ivy describes it with configurations. Neither can say "this component has a variant for compiling, another for running, a third that ships sample data, and a fourth for Java 8". Gradle can, because it models a published or project component as a set of variants:

  • a component is a module at a version (io.tidepool:tidepool-model:1.0);

  • a variant is one way of consuming it: its own attributes (key-value pairs), its own dependencies, its own artifacts and its own capabilities;

  • an attribute is a typed key such as org.gradle.usage with a value such as java-api;

  • a capability is a group:name:version that the variant provides; by default it is the component itself.

A consumer does not ask for a configuration by name. It asks for attributes ("a library, for use at compile time, for Java 26") and Gradle selects the variant of each dependency that matches. This is variant-aware dependency resolution, the engine behind the Java plugins, Android, Kotlin Multiplatform and every other ecosystem plugin. The selection happens twice: once for the dependency graph (which variant of each component) and once for the artifacts (which files of that variant, possibly transformed).

The attributes that the JVM plugins set are the vocabulary the rest of this page uses.

Attribute Typical values Meaning

org.gradle.usage

java-api, java-runtime

Compile-time API or runtime use. Second in the precedence order, after the category.

org.gradle.category

library, platform, documentation, verification

What kind of thing the variant is. A BOM is a platform, the sources jar is documentation.

org.gradle.libraryelements

jar, classes, resources

The shape of the artifacts: a jar or a directory of classes.

org.gradle.dependency.bundling

external

Whether the dependencies are bundled in (a shadow jar) or external.

org.gradle.jvm.version

17, 26

The Java version the binaries target. A consumer on Java 26 accepts a variant built for Java 26 or lower.

org.gradle.jvm.environment

standard-jvm, android

The kind of JVM.

org.gradle.docstype

sources, javadoc

Which kind of documentation, for variants of category documentation.

org.gradle.status

release

The component-level status, set on published metadata.

Variants arrived in stages: Gradle Module Metadata 1.0 and feature variants in Gradle 5.3, test fixtures in 5.6, and Gradle Module Metadata published by default in 6.0. The explicit configuration roles used below (consumable, resolvable, dependencyScope) are the Gradle 8.4 API. Everything on this page behaves the same on Gradle 8.14 apart from deprecations that are named where they matter (not run here).

The example build

All the examples use one small three-project build with a producer, tidepool-model, and two consumers, tidepool-engine and tidepool-cli. tidepool-model is a java-library that

  • registers an optional feature, csv, in its own source set, with its own dependency on Apache Commons CSV;

  • applies java-test-fixtures;

  • declares two custom outgoing variants, tideData and harmonicsData, that carry sample data files and are told apart by a custom attribute, io.tidepool.flavor;

  • publishes all of it to a file:// repository under its own build directory with maven-publish and ivy-publish.

The sample data is data/stations.csv (the three stations of the tidepool example) and a second file, data/harmonics.csv, with three rows of constituent speeds.

The settings file declares the repository once and fails the build when a project declares its own:

Kotlin DSL (settings.gradle.kts)
rootProject.name = "tidepool"

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL (settings.gradle)
rootProject.name = 'tidepool'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'

The producer, with the feature, the test fixtures, the two custom variants and the publications:

Kotlin DSL (tidepool-model/build.gradle.kts)
plugins {
    `java-library`
    `java-test-fixtures`
    `maven-publish`
    `ivy-publish`
}

group = "io.tidepool"
version = "1.0"

val flavor = Attribute.of("io.tidepool.flavor", String::class.java)

java {
    registerFeature("csv") {
        usingSourceSet(sourceSets.create("csv"))
        withSourcesJar()
    }
}

dependencies {
    "csvApi"("org.apache.commons:commons-csv:1.14.1")
}

// two custom outgoing variants, one per flavor of sample data
fun dataVariant(name: String, flavorName: String, file: String) =
    configurations.consumable(name) {
        description = "Sample data, flavor $flavorName"
        attributes {
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named<Category>("tidepool-sample"))
            attribute(Usage.USAGE_ATTRIBUTE, objects.named<Usage>("tidepool-data"))
            attribute(flavor, flavorName)
        }
        outgoing.artifact(rootProject.file(file)) {
            classifier = flavorName
        }
    }

val tideData = dataVariant("tideData", "tides", "data/stations.csv")
val harmonicsData = dataVariant("harmonicsData", "harmonics", "data/harmonics.csv")

// publish the two data variants together with the library variants
val javaComponent = components["java"] as AdhocComponentWithVariants
javaComponent.addVariantsFromConfiguration(tideData.get()) { }
javaComponent.addVariantsFromConfiguration(harmonicsData.get()) { }

publishing {
    publications {
        create<IvyPublication>("ivy") {
            from(javaComponent)
        }
        create<MavenPublication>("maven") {
            from(javaComponent)
            suppressPomMetadataWarningsFor("csvApiElements")
            suppressPomMetadataWarningsFor("csvRuntimeElements")
            suppressPomMetadataWarningsFor("csvSourcesElements")
            suppressPomMetadataWarningsFor("testFixturesApiElements")
            suppressPomMetadataWarningsFor("testFixturesRuntimeElements")
        }
    }
    repositories {
        ivy {
            name = "localIvy"
            url = uri(rootProject.layout.buildDirectory.dir("ivy-repo"))
        }
        maven {
            name = "local"
            url = uri(rootProject.layout.buildDirectory.dir("repo"))
        }
    }
}

// -PnoGmm: stop publishing the .module file
if (providers.gradleProperty("noGmm").isPresent) {
    tasks.withType<GenerateModuleMetadata>().configureEach {
        enabled = false
    }
}
Groovy DSL (tidepool-model/build.gradle)
plugins {
    id 'java-library'
    id 'java-test-fixtures'
    id 'maven-publish'
    id 'ivy-publish'
}

group = 'io.tidepool'
version = '1.0'

def flavor = Attribute.of('io.tidepool.flavor', String)

java {
    registerFeature('csv') {
        usingSourceSet(sourceSets.create('csv'))
        withSourcesJar()
    }
}

dependencies {
    csvApi 'org.apache.commons:commons-csv:1.14.1'
}

// two custom outgoing variants, one per flavor of sample data
def dataVariant = { String variantName, String flavorName, String path ->
    configurations.consumable(variantName) {
        description = "Sample data, flavor $flavorName"
        attributes {
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, 'tidepool-sample'))
            attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, 'tidepool-data'))
            attribute(flavor, flavorName)
        }
        outgoing.artifact(rootProject.file(path)) {
            classifier = flavorName
        }
    }
}

def tideData = dataVariant.call('tideData', 'tides', 'data/stations.csv')
def harmonicsData = dataVariant.call('harmonicsData', 'harmonics', 'data/harmonics.csv')

// publish the two data variants together with the library variants
def javaComponent = components.java
javaComponent.addVariantsFromConfiguration(tideData.get()) { }
javaComponent.addVariantsFromConfiguration(harmonicsData.get()) { }

publishing {
    publications {
        ivy(IvyPublication) {
            from javaComponent
        }
        maven(MavenPublication) {
            from javaComponent
            suppressPomMetadataWarningsFor 'csvApiElements'
            suppressPomMetadataWarningsFor 'csvRuntimeElements'
            suppressPomMetadataWarningsFor 'csvSourcesElements'
            suppressPomMetadataWarningsFor 'testFixturesApiElements'
            suppressPomMetadataWarningsFor 'testFixturesRuntimeElements'
        }
    }
    repositories {
        ivy {
            name = 'localIvy'
            url = rootProject.layout.buildDirectory.dir('ivy-repo')
        }
        maven {
            name = 'local'
            url = rootProject.layout.buildDirectory.dir('repo')
        }
    }
}

// -PnoGmm: stop publishing the .module file
if (providers.gradleProperty('noGmm').present) {
    tasks.withType(GenerateModuleMetadata).configureEach {
        enabled = false
    }
}

configurations.consumable(name) { ... } creates a configuration that can only be consumed (it declares outgoing variants and cannot hold dependencies). It is the Gradle 8.4 replacement for the old configurations.create(name) { isCanBeResolved = false } idiom. The Groovy version defines the helper as a closure rather than as a method: a def variable of a Groovy script is not visible inside a script method, and the failure is confusing, because flavor then silently resolves to the -Pflavor project property of the later examples and attribute(flavor, flavorName) is called with two strings (No signature of method: ...attribute() is applicable for argument types: (String, String)).

tidepool-engine asks for the csv feature of the model and depends on its test fixtures:

Kotlin DSL (tidepool-engine/build.gradle.kts)
plugins {
    `java-library`
}

dependencies {
    api(project(":tidepool-model"))
    // ask for the optional "csv" feature of tidepool-model
    implementation(project(":tidepool-model")) {
        capabilities {
            requireFeature("csv")
        }
    }
    testImplementation(testFixtures(project(":tidepool-model")))
}
Groovy DSL (tidepool-engine/build.gradle)
plugins {
    id 'java-library'
}

dependencies {
    api project(':tidepool-model')
    // ask for the optional "csv" feature of tidepool-model
    implementation(project(':tidepool-model')) {
        capabilities {
            requireFeature('csv')
        }
    }
    testImplementation testFixtures(project(':tidepool-model'))
}

tidepool-cli is the application, and the consumer of the sample data and of the artifact transform; its build file is shown in the section on attribute matching and explained there and in the section on artifact transforms. The two java files of the csv feature and of the fixtures are one-class stubs and not shown. The whole build, including the part the later sections add, compiles and passes ./gradlew build with --configuration-cache on Gradle 9.8.1.

Seeing the variants

The outgoing variants report

outgoingVariants lists what a project offers to consumers. Run in the producer it prints, for the Java library plugin alone, apiElements and runtimeElements; with the feature, the fixtures and the custom variants it prints these:

$ ./gradlew -q :tidepool-model:outgoingVariants | grep '^Variant'
Variant apiElements
Variant csvApiElements
Variant csvRuntimeElements
Variant csvSourcesElements
Variant harmonicsData
Variant mainSourceElements (i)
Variant runtimeElements
Variant testFixturesApiElements
Variant testFixturesRuntimeElements
Variant testResultsElementsForTest (i)
Variant tideData

Three of them in full. The csv feature’s runtime variant has its own capability, which is what lets a consumer ask for it:

--------------------------------------------------
Variant csvRuntimeElements
--------------------------------------------------
Runtime elements for the 'csv' feature.

Capabilities
    - io.tidepool:tidepool-model-csv:1.0
Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 26
    - org.gradle.libraryelements     = jar
    - org.gradle.usage               = java-runtime
Artifacts
    - build/libs/tidepool-model-1.0-csv.jar (artifactType = jar, classifier = csv)

The custom data variants are variants like any other, with their own attributes and artifacts. The artifact type (csv) comes from the file extension and the classifier from the build file:

--------------------------------------------------
Variant tideData
--------------------------------------------------
Sample data, flavor tides

Capabilities
    - io.tidepool:tidepool-model:1.0 (default capability)
Attributes
    - io.tidepool.flavor  = tides
    - org.gradle.category = tidepool-sample
    - org.gradle.usage    = tidepool-data
Artifacts
    - ../data/stations.csv (artifactType = csv, classifier = tides)

--------------------------------------------------
Variant harmonicsData
--------------------------------------------------
Sample data, flavor harmonics

Capabilities
    - io.tidepool:tidepool-model:1.0 (default capability)
Attributes
    - io.tidepool.flavor  = harmonics
    - org.gradle.category = tidepool-sample
    - org.gradle.usage    = tidepool-data
Artifacts
    - ../data/harmonics.csv (artifactType = csv, classifier = harmonics)

Each variant of the Java plugins also lists secondary variants, extra sets of artifacts that take part in artifact selection but not in graph resolution. apiElements has classes; runtimeElements has classes and resources. They are what lets a project on the same build consume the build/classes/java/main directory of its neighbour instead of waiting for its jar:

--------------------------------------------------
Variant apiElements
--------------------------------------------------
API elements for the 'main' feature.

Capabilities
    - io.tidepool:tidepool-model:1.0 (default capability)
Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 26
    - org.gradle.libraryelements     = jar
    - org.gradle.usage               = java-api
Artifacts
    - build/libs/tidepool-model-1.0.jar (artifactType = jar)

Secondary Variants (*)

    --------------------------------------------------
    Secondary Variant classes
    --------------------------------------------------
    Directories containing compiled class files for main.

    Attributes
        - org.gradle.category            = library
        - org.gradle.dependency.bundling = external
        - org.gradle.jvm.version         = 26
        - org.gradle.libraryelements     = classes
        - org.gradle.usage               = java-api
    Artifacts
        - build/classes/java/main (artifactType = java-classes-directory)

Variants whose attributes carry a status marked incubating (the (i) in the names above, here mainSourceElements and testResultsElementsForTest, category verification) are used by tools such as test-report aggregation and are not relevant to compiling or running.

The artifactType shown on each artifact line is the attribute that artifact views and transforms work on, see Artifact transforms.

The resolvable configurations report

The consumer side is resolvableConfigurations. It prints a configuration’s requested attributes and the attributes that have compatibility and disambiguation rules (io.tidepool.flavor is in both lists because of the rules added to the consumer below). The disambiguation list shows the precedence (the number in brackets) that decides which attribute is considered first:

$ ./gradlew -q :tidepool-cli:resolvableConfigurations --configuration dataPath
--------------------------------------------------
Configuration dataPath
--------------------------------------------------
Attributes
    - io.tidepool.flavor  = tides
    - org.gradle.category = tidepool-sample
    - org.gradle.usage    = tidepool-data
Extended Configurations
    - dataDeps
--------------------------------------------------
Compatibility Rules
--------------------------------------------------
The following Attributes have compatibility rules defined.
    - io.tidepool.flavor
    - org.gradle.dependency.bundling
    - org.gradle.jvm.environment
    - org.gradle.jvm.version
    - org.gradle.libraryelements
    - org.gradle.plugin.api-version
    - org.gradle.usage
--------------------------------------------------
Disambiguation Rules
--------------------------------------------------
The following Attributes have disambiguation rules defined.
    - io.tidepool.flavor
    - org.gradle.category (1)
    - org.gradle.dependency.bundling (5)
    - org.gradle.jvm.environment (6)
    - org.gradle.jvm.version (3)
    - org.gradle.libraryelements (4)
    - org.gradle.plugin.api-version
    - org.gradle.usage (2)
(#): Attribute disambiguation precedence

Which variant did a dependency get

The report that answers "why did I get this file" is dependencyInsight, which prints the variant chosen and the provided and requested attributes side by side. For tidepool-engine, which asked for the csv feature, two variants of the same project component are selected, the main runtime variant and the feature’s:

$ ./gradlew -q :tidepool-engine:dependencyInsight --configuration runtimeClasspath --dependency tidepool-model
project ':tidepool-model'
  Variant runtimeElements:
    | Attribute Name                 | Provided     | Requested    |
    |--------------------------------|--------------|--------------|
    | org.gradle.category            | library      | library      |
    | org.gradle.dependency.bundling | external     | external     |
    | org.gradle.jvm.version         | 26           | 26           |
    | org.gradle.libraryelements     | jar          | jar          |
    | org.gradle.usage               | java-runtime | java-runtime |
    | org.gradle.jvm.environment     |              | standard-jvm |
  Variant csvRuntimeElements:
    | Attribute Name                 | Provided     | Requested    |
    |--------------------------------|--------------|--------------|
    | org.gradle.category            | library      | library      |
    | org.gradle.dependency.bundling | external     | external     |
    | org.gradle.jvm.version         | 26           | 26           |
    | org.gradle.libraryelements     | jar          | jar          |
    | org.gradle.usage               | java-runtime | java-runtime |
    | org.gradle.jvm.environment     |              | standard-jvm |
project ':tidepool-model'
\--- runtimeClasspath
project ':tidepool-model'
\--- runtimeClasspath
A web-based, searchable dependency report is available by adding the --scan option.

and the dependency of the feature, Commons CSV, reaches the engine through it, not through a declaration of its own:

$ ./gradlew -q :tidepool-engine:dependencyInsight --configuration runtimeClasspath --dependency commons-csv
org.apache.commons:commons-csv:1.14.1
  Variant runtime:
    | Attribute Name                 | Provided     | Requested    |
    |--------------------------------|--------------|--------------|
    | org.gradle.status              | release      |              |
    | org.gradle.category            | library      | library      |
    | org.gradle.libraryelements     | jar          | jar          |
    | org.gradle.usage               | java-runtime | java-runtime |
    | org.gradle.dependency.bundling |              | external     |
    | org.gradle.jvm.environment     |              | standard-jvm |
    | org.gradle.jvm.version         |              | 26           |
org.apache.commons:commons-csv:1.14.1
\--- project ':tidepool-model'
     \--- runtimeClasspath
A web-based, searchable dependency report is available by adding the --scan option.

The attribute matching algorithm

When a consumer’s resolvable configuration meets a component, Gradle runs one algorithm over the candidate variants. In the order of the manual:

Step What happens

1

Compatible candidates. Each candidate attribute must be missing, equal to the requested value, or accepted by the attribute’s compatibility rule. A candidate with an incompatible value is eliminated. A candidate that lacks an attribute the consumer asked for is not eliminated; it is marked as missing it.

2

Most matching attributes. If one candidate is a strict superset of the others (it matches more attributes, or has no missing attributes compared to another), it wins.

3

Disambiguation by requested attributes. The requested attributes are visited in precedence order (category, usage, jvm.version, libraryelements, bundling, jvm.environment for the JVM; then unordered ones); each attribute’s disambiguation rule may choose one candidate. An attribute that has the same value on all remaining candidates is ignored.

4

Extra attributes. Attributes that only some candidates have (and the consumer did not ask for) are used to prefer the candidates that lack them.

5

Extra attribute presence. As a last resort, candidates that carry an extra attribute are removed.

6

Failure. If no candidate remains, resolution fails with a no matching variant error that lists all candidates; if several remain, with an ambiguity error.

Two cases skip the algorithm: a producer with no variants (a plain artifact) and a consumer that selects a configuration by name (dependencies { implementation(project(":a", "someConfiguration")) }).

Asking for sample data

tidepool-cli consumes the sample data of the model. The dependency is declared on a dependency scope configuration, which cannot be resolved, and the resolvable configuration extends it and carries the requested attributes. The flavor is taken from the -Pflavor property so that every case below can be run; any means "do not ask for a flavor at all". The file is shown in full: the attribute rules in the middle are explained in the next sections, and so are the transform and the showRows task at the end.

Kotlin DSL (tidepool-cli/build.gradle.kts)
import org.gradle.api.artifacts.type.ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE

plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
}

application {
    mainClass = "io.tidepool.cli.Main"
}

val flavor = Attribute.of("io.tidepool.flavor", String::class.java)

// "tides-v2" may also be served by a "tides" variant
abstract class TidesFallback : AttributeCompatibilityRule<String> {
    override fun execute(details: CompatibilityCheckDetails<String>) {
        if (details.consumerValue == "tides-v2" && details.producerValue == "tides") {
            details.compatible()
        }
    }
}

// when no flavor is requested at all, prefer "tides"
abstract class PreferTides : AttributeDisambiguationRule<String> {
    override fun execute(details: MultipleCandidatesDetails<String>) {
        if (details.consumerValue == null && "tides" in details.candidateValues) {
            details.closestMatch("tides")
        }
    }
}

dependencies.attributesSchema {
    attribute(flavor) {
        compatibilityRules.add(TidesFallback::class)
        disambiguationRules.add(PreferTides::class)
    }
}

// where the dependency is declared ...
val dataDeps = configurations.dependencyScope("dataDeps")

// ... and a resolvable configuration that asks for sample data instead of a library
val dataPath = configurations.resolvable("dataPath") {
    extendsFrom(dataDeps.get())
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named<Category>("tidepool-sample"))
        attribute(Usage.USAGE_ATTRIBUTE, objects.named<Usage>("tidepool-data"))
        val wanted = providers.gradleProperty("flavor").getOrElse("tides")
        if (wanted != "any") {
            attribute(flavor, wanted)
        }
    }
}

dependencies {
    add("dataDeps", project(":tidepool-model"))
}

tasks.register("showData") {
    val files = dataPath.map { it.incoming.files }
    val root = rootProject.layout.projectDirectory.asFile
    doLast {
        files.get().forEach { println(it.relativeTo(root)) }
    }
}

// CountRows (csv file -> number of data rows) lives in buildSrc: a transform must be a static class
dependencies {
    registerTransform(CountRows::class) {
        from.attribute(ARTIFACT_TYPE_ATTRIBUTE, "csv")
        to.attribute(ARTIFACT_TYPE_ATTRIBUTE, "rows")
    }
}

tasks.register("showRows") {
    val rows = dataPath.get().incoming.artifactView {
        attributes { attribute(ARTIFACT_TYPE_ATTRIBUTE, "rows") }
    }.files
    inputs.files(rows)
    doLast {
        rows.forEach { println("${it.name}: ${it.readText().trim()}") }
    }
}
Groovy DSL (tidepool-cli/build.gradle)
import org.gradle.api.artifacts.transform.TransformParameters
import static org.gradle.api.artifacts.type.ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE

plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}

def flavor = Attribute.of('io.tidepool.flavor', String)

// "tides-v2" may also be served by a "tides" variant
abstract class TidesFallback implements AttributeCompatibilityRule<String> {
    void execute(CompatibilityCheckDetails<String> details) {
        if (details.consumerValue == 'tides-v2' && details.producerValue == 'tides') {
            details.compatible()
        }
    }
}

// when no flavor is requested at all, prefer "tides"
abstract class PreferTides implements AttributeDisambiguationRule<String> {
    void execute(MultipleCandidatesDetails<String> details) {
        if (details.consumerValue == null && 'tides' in details.candidateValues) {
            details.closestMatch('tides')
        }
    }
}

dependencies.attributesSchema {
    attribute(flavor) {
        compatibilityRules.add(TidesFallback)
        disambiguationRules.add(PreferTides)
    }
}

// where the dependency is declared ...
def dataDeps = configurations.dependencyScope('dataDeps')

// ... and a resolvable configuration that asks for sample data instead of a library
def dataPath = configurations.resolvable('dataPath') {
    extendsFrom(dataDeps.get())
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, 'tidepool-sample'))
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, 'tidepool-data'))
        def wanted = providers.gradleProperty('flavor').getOrElse('tides')
        if (wanted != 'any') {
            attribute(flavor, wanted)
        }
    }
}

dependencies {
    add('dataDeps', project(':tidepool-model'))
}

tasks.register('showData') {
    def files = dataPath.map { it.incoming.files }
    def root = rootProject.layout.projectDirectory.asFile
    doLast {
        files.get().each { println root.toPath().relativize(it.toPath()) }
    }
}

// CountRows (csv file -> number of data rows) is defined in the script this time
abstract class CountRows implements TransformAction<TransformParameters.None> {
    @PathSensitive(PathSensitivity.NAME_ONLY)
    @InputArtifact
    abstract Provider<FileSystemLocation> getInput()

    void transform(TransformOutputs outputs) {
        def csv = input.get().asFile
        def rows = csv.readLines().size() - 1
        outputs.file(csv.name.replace('.csv', '.rows')).text = "$rows\n"
        println "transforming ${csv.name}"
    }
}

dependencies {
    registerTransform(CountRows) {
        from.attribute(ARTIFACT_TYPE_ATTRIBUTE, 'csv')
        to.attribute(ARTIFACT_TYPE_ATTRIBUTE, 'rows')
    }
}

tasks.register('showRows') {
    def rows = dataPath.get().incoming.artifactView {
        attributes { attribute(ARTIFACT_TYPE_ATTRIBUTE, 'rows') }
    }.files
    inputs.files(rows)
    doLast {
        rows.each { println "${it.name}: ${it.text.trim()}" }
    }
}

The first error most people hit is declaring the dependency on the resolvable configuration:

* Where:
Build file '/work/tidepool/tidepool-cli/build.gradle.kts' line: 27
* What went wrong:
Dependencies can not be declared against the `dataPath` configuration.

Gradle 8.4 split the roles for that reason: a dependencyScope configuration collects declarations, a resolvable one is the root of a graph, a consumable one models outgoing variants. A configuration created with the plain create can be all three, which is how the old build files worked and why a mistake there was not caught.

Now the four requests, with the data task that prints the files Gradle selected (the first run in each listing prints only the paths):

$ ./gradlew -q --configuration-cache :tidepool-cli:showData -Pflavor=tides
data/stations.csv
$ ./gradlew -q --configuration-cache :tidepool-cli:showData -Pflavor=harmonics
data/harmonics.csv
$ ./gradlew -q --configuration-cache :tidepool-cli:showData -Pflavor=tides-v2
data/stations.csv
$ ./gradlew -q --configuration-cache :tidepool-cli:showData -Pflavor=any
data/stations.csv

tides-v2 was not published by anybody; it is served by the tides variant because of the compatibility rule shown below. any asks for no flavor at all and still gets the tides file because of the disambiguation rule.

A variant that does not say anything is compatible

This was run before the build files above had the org.gradle.category attribute (the producer and consumer then requested only org.gradle.usage and io.tidepool.flavor). Asking for a flavor nobody has should fail:

$ ./gradlew -q --configuration-cache :tidepool-cli:showData -Pflavor=ephemeris
tidepool-model/src/main/resources
tidepool-model/src/main/java

It did not fail. The only variants that declare a different flavor were eliminated in step 1, but variants that declare no flavor and no usage at all, such as the verification variants with the src/main directories, are not incompatible, only "missing" the attributes, so one of them was selected and the task printed source directories. This is the most common surprise with custom attributes: a variant that omits an attribute accepts any value of it. The cure is to make the request specific enough that the unrelated variants are incompatible on some other attribute. A dedicated category, here tidepool-sample, is the usual choice, and it is what the build files above do. With it the same request fails as it should:

$ ./gradlew -q :tidepool-cli:showData -Pflavor=ephemeris

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-cli:showData' (registered in build file 'tidepool-cli/build.gradle.kts').
> Could not resolve all files for configuration ':tidepool-cli:dataPath'.
   > Could not resolve project ':tidepool-model'.
     Required by:
         project ':tidepool-cli'
      > No matching variant of project ':tidepool-model' was found. The consumer was configured to find a component of category 'tidepool-sample' for use during 'tidepool-data', as well as attribute 'io.tidepool.flavor' with value 'ephemeris' but:
          - Variant 'apiElements':
              - Incompatible because this component declares a library for use during compile-time and the consumer needed a component of category 'tidepool-sample' for use during 'tidepool-data'
              - Other compatible attribute:
                  - Doesn't say anything about io.tidepool.flavor (required 'ephemeris')
          ...
          - Variant 'harmonicsData' declares a component of category 'tidepool-sample' for use during 'tidepool-data':
              - Incompatible because this component declares a component, as well as attribute 'io.tidepool.flavor' with value 'harmonics' and the consumer needed a component, as well as attribute 'io.tidepool.flavor' with value 'ephemeris'
          - Variant 'mainSourceElements':
              - Incompatible because this component declares a component of category 'verification' and the consumer needed a component of category 'tidepool-sample'
              - Other compatible attributes:
                  - Doesn't say anything about io.tidepool.flavor (required 'ephemeris')
          ...
          - Variant 'tideData' declares a component of category 'tidepool-sample' for use during 'tidepool-data':
              - Incompatible because this component declares a component, as well as attribute 'io.tidepool.flavor' with value 'tides' and the consumer needed a component, as well as attribute 'io.tidepool.flavor' with value 'ephemeris'

Each candidate is listed with the reason it was rejected: the library variants because their category is library, the two data variants because their flavor differs, the verification variants because their category is verification. When a no-match error is not obvious, read the candidate that is closest to what you wanted, which is the data variant.

The ambiguity error is the opposite: nothing was rejected, so Gradle cannot choose. It was produced before the disambiguation rule below was added to the build (the consumer did not ask for a flavor):

$ ./gradlew -q :tidepool-cli:showData -Pflavor=any
* What went wrong:
Execution failed for task ':tidepool-cli:showData' (registered in build file 'tidepool-cli/build.gradle.kts').
> Could not resolve all files for configuration ':tidepool-cli:dataPath'.
   > Could not resolve project ':tidepool-model'.
     Required by:
         project ':tidepool-cli'
      > The consumer was configured to find a component of category 'tidepool-sample' for use during 'tidepool-data'. There are several available matching variants of project ':tidepool-model'
        The only attribute distinguishing these variants is 'io.tidepool.flavor'. Add this attribute to the consumer's configuration to resolve the ambiguity:
          - Value: 'harmonics' selects variant: 'harmonicsData'
          - Value: 'tides' selects variant: 'tideData'

Its advice is exact: add the attribute to the consumer. When the consumer cannot know, a disambiguation rule picks a default.

Note that the help links at the end of both messages (variant_model.html#sub:variant-no-match, variant_model.html#sub:variant-ambiguity and variant_attributes.html#sec:abm_algorithm) point to anchors that do not exist in the 9.8.1 manual; the algorithm is documented at variant_aware_resolution.html#sec:abm-algorithm.

Compatibility and disambiguation rules

An attribute can carry two kinds of rule, registered in the attributes schema of the consumer build (or of the whole build, in the settings file):

  • a compatibility rule says that a producer value is acceptable for a different consumer value (tides for a request of tides-v2). It is called in step 1 and may call compatible() or leave the pair incompatible.

  • a disambiguation rule chooses among several remaining candidates (step 3). It may call closestMatch(value).

Both are abstract classes that Gradle instantiates itself. Classes declared in a Kotlin build script are accepted for attribute rules, as the TidesFallback and PreferTides classes and the attributesSchema block of the tidepool-cli build file above show; artifact transforms are stricter, see Artifact transforms.

With the rules, -Pflavor=tides-v2 and -Pflavor=any print data/stations.csv, as in the listing above, and the rules show up in the resolvable configurations report (io.tidepool.flavor is listed under both). The ecosystem plugins ship the same kind of rules: it is why a consumer on Java 26 accepts a library built for Java 17 (jvm.version) and why a request for libraryelements = classes accepts a jar variant, as the next output shows for the test fixtures.

Artifact transforms

Sometimes the consumer wants the same variant in a different shape: the classes directory instead of a jar, the instrumented jar, a checksum, a count. The producer cannot publish every shape, and the consumer should not have to run a task for each. An artifact transform converts an artifact of one artifactType (or other attribute) to another during resolution, and Gradle chains transforms when no single one matches. A transform is like a task: it has inputs and outputs, it is cached and up to date like one, and it runs lazily, only for the artifacts that the consumer’s view requests.

The example turns the csv artifact of a data variant into a one-line file with its number of data rows. A transform is an abstract class implementing TransformAction, with its input injected as a Provider:

Kotlin DSL (buildSrc/build.gradle.kts)
plugins {
    `kotlin-dsl`
}

repositories {
    mavenCentral()
}
Kotlin DSL (buildSrc/src/main/kotlin/CountRows.kt)
import org.gradle.api.artifacts.transform.InputArtifact
import org.gradle.api.artifacts.transform.TransformAction
import org.gradle.api.artifacts.transform.TransformOutputs
import org.gradle.api.artifacts.transform.TransformParameters
import org.gradle.api.file.FileSystemLocation
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity

/** Artifact transform: a csv file becomes a file holding its number of data rows. */
abstract class CountRows : TransformAction<TransformParameters.None> {
    @get:PathSensitive(PathSensitivity.NAME_ONLY)
    @get:InputArtifact
    abstract val input: Provider<FileSystemLocation>

    override fun transform(outputs: TransformOutputs) {
        val csv = input.get().asFile
        val rows = csv.readLines().size - 1
        outputs.file(csv.nameWithoutExtension + ".rows").writeText("$rows\n")
        println("transforming ${csv.name}")
    }
}

It is in buildSrc (or any included build) because of the first classic error. Written in the build script, the Kotlin class is an inner class of the generated script class and Gradle refuses to register it:

$ ./gradlew -q :tidepool-cli:showRows
* What went wrong:
Could not register artifact transform Build_gradle.CountRows (from {artifactType=csv} to {artifactType=rows}).
> Class Build_gradle.CountRows is a non-static inner class.

In the Groovy DSL the same class worked when written in the build script; the only thing Groovy needed was the import of TransformParameters, because the nested None is not in the default imports (unable to resolve class TransformParameters.None otherwise). The best-practice location for shared build logic is an included build-logic build instead of buildSrc, see Convention Plugins & Sharing Build Logic.

The end of the tidepool-cli build file above registers the transform (registerTransform, from csv to rows on the artifactType attribute) and reads the transformed files through an artifact view in the showRows task. In the Groovy DSL the transform class is in the same file.

The task declares the view’s files as its inputs. That is the second classic error: if the files are only read in the action (dataPath.map { ... }.get() inside doLast), nothing tells Gradle that the producer project has to be built, and the transform of a project artifact fails (Could not access project ':tidepool-model'. No task declared this project as part of an input, so it was not scheduled). Declaring inputs.files(rows) fixed it, and the example is compatible with the configuration cache.

$ ./gradlew -q --configuration-cache :tidepool-cli:showRows -Pflavor=tides
transforming stations.csv
stations.rows: 3
$ ./gradlew -q --configuration-cache :tidepool-cli:showRows -Pflavor=harmonics
transforming harmonics.csv
harmonics.rows: 3

On the first run in a fresh project the transform logs transforming stations.csv and then transforming harmonics.csv for the second flavor; both lines are printed by the transform itself, and a rerun of the same request reuses the cached result and prints only the line of the task. The -Pflavor selected the variant first, then the transform converted that variant’s single file; the rows view never saw the other flavor.

A transform with parameters, an incremental transform and transform chains are described in the manual. Secondary variants such as classes (see above) are the cheaper alternative when the producer can offer the other shape itself.

Feature variants

A feature variant is an optional part of a library with its own source set, its own dependencies and its own capability. It is Gradle’s answer to Maven’s <optional>true</optional>, but typed: instead of a dependency that may or may not be there, the feature has a name and a consumer asks for it. registerFeature("csv") in the model build above created:

  • the source set csv (src/csv/java), which the main source set does not see;

  • the configurations csvApi and csvImplementation for the feature’s dependencies;

  • the variants csvApiElements, csvRuntimeElements and, with withSourcesJar(), csvSourcesElements, all with the capability io.tidepool:tidepool-model-csv:1.0, which is the component’s group:name with -csv appended. The capability is how a consumer addresses the feature.

The consumer in tidepool-engine wrote

implementation(project(":tidepool-model")) {
    capabilities {
        requireFeature("csv")
    }
}

and Gradle added the csv variant to the same component, with the transitive dependency on Commons CSV, as the dependencyInsight output above shows. A consumer that does not ask never sees Commons CSV: in the published-module consumer below, -Pcsv=false resolves tidepool-model and nothing else. A feature can also be registered on the main source set (usingSourceSet(sourceSets.main)), in which case it has no classes of its own and only contributes dependencies.

In a publication a feature variant maps to the formats as follows (the table is from the manual and the Maven and Ivy cells are confirmed by the published files shown later):

Format Variant artifacts Feature dependencies Capability

Gradle Module Metadata

uploaded

in the feature’s variants, required by the consumer that asks for the feature

published

POM

uploaded, with a distinct classifier

<optional>true</optional> dependencies

not published (warning at publication)

ivy.xml

uploaded, with a distinct classifier

extra configurations (csvApiElements, csvRuntimeElements); not in default

not published (warning at publication)

Test fixtures

java-test-fixtures (Gradle 5.6) is feature variants specialised for sharing test helpers. It creates a testFixtures source set (src/testFixtures/java) with testFixturesApi and testFixturesImplementation, makes the fixtures see the main classes (the plugin adds that dependency itself; adding testFixturesApi(project) by hand is redundant and, in 9.8.1, hits the deprecation of passing a Project object as a dependency notation: Using a Project object as a dependency notation has been deprecated), makes the tests see the fixtures, and exposes two variants, testFixturesApiElements and testFixturesRuntimeElements, with the capability io.tidepool:tidepool-model-test-fixtures:1.0 (see the outgoing variants above). Another project consumes them with testImplementation(testFixtures(project(":tidepool-model"))), as tidepool-engine does, and an external build with testFixtures("io.tidepool:tidepool-model:1.0").

The consumer of the published module below declares that, and dependencyInsight shows the two variants of the one component on the test compile classpath, and the libraryelements compatibility rule at work (the consumer requested classes, the variants provide jar):

$ ./gradlew -q dependencyInsight --configuration testCompileClasspath --dependency tidepool-model -Pcsv=false
io.tidepool:tidepool-model:1.0
  Variant apiElements:
    | Attribute Name                 | Provided | Requested    |
    |--------------------------------|----------|--------------|
    | org.gradle.status              | release  |              |
    | org.gradle.category            | library  | library      |
    | org.gradle.dependency.bundling | external | external     |
    | org.gradle.jvm.version         | 26       | 26           |
    | org.gradle.libraryelements     | jar      | classes      |
    | org.gradle.usage               | java-api | java-api     |
    | org.gradle.jvm.environment     |          | standard-jvm |
  Variant testFixturesApiElements:
    | Attribute Name                 | Provided | Requested    |
    |--------------------------------|----------|--------------|
    | org.gradle.status              | release  |              |
    | org.gradle.category            | library  | library      |
    | org.gradle.dependency.bundling | external | external     |
    | org.gradle.jvm.version         | 26       | 26           |
    | org.gradle.libraryelements     | jar      | classes      |
    | org.gradle.usage               | java-api | java-api     |
    | org.gradle.jvm.environment     |          | standard-jvm |
io.tidepool:tidepool-model:1.0
\--- testCompileClasspath
io.tidepool:tidepool-model:1.0
\--- testCompileClasspath
io.tidepool:tidepool-model:1.0
\--- io.tidepool:tidepool-model:1.0 (*)
(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.
A web-based, searchable dependency report is available by adding the --scan option.

The fixtures are published as an additional jar with the test-fixtures classifier (visible in the repository listing below).

Gradle Module Metadata

Variants exist at the project level. For a published component they must be written down somewhere, and neither pom.xml nor ivy.xml has room for attributes, capabilities or variant-specific dependencies. Gradle Module Metadata (GMM) is that format: a JSON file named <artifactId>-<version>.module, published next to the POM or Ivy descriptor, that lists every variant with its attributes, dependencies, constraints, files (with size and checksums) and capabilities. Gradle 5.3 published and consumed version 1.0; since Gradle 6.0 maven-publish and ivy-publish generate it by default.

Publishing the model to a repository under the build directory:

$ ./gradlew -q :tidepool-model:publishMavenPublicationToLocalRepository
$ find build/repo -type f ! -name "*.md5" ! -name "*.sha*" | sort
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-csv-sources.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-csv.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-harmonics.csv
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-test-fixtures.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-tides.csv
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0.module
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0.pom
build/repo/io/tidepool/tidepool-model/maven-metadata.xml

The publication was created with from(components["java"]); the custom variants joined it because the build added them to the component with addVariantsFromConfiguration. Without that, a custom consumable configuration is not part of the publication. The two data files had to get distinct classifiers (tides, harmonics) for a second reason, which is a real error:

$ ./gradlew -q :tidepool-model:publishMavenPublicationToLocalRepository
* What went wrong:
Execution failed for task ':tidepool-model:publishMavenPublicationToLocalRepository' (registered by plugin 'org.gradle.maven-publish').
> Failed to publish publication 'maven' to repository 'local'
   > Invalid publication 'maven': multiple artifacts with the identical extension and classifier ('csv', 'null').

Two artifacts of one publication with the same extension and classifier would be the same file in a Maven layout.

The module file is long (273 lines for this component); these are the header, the feature variant that carries the dependency and the capability, and one of the data variants. Size and checksum lines are replaced by ...:

{
  "formatVersion": "1.1",
  "component": {
    "group": "io.tidepool",
    "module": "tidepool-model",
    "version": "1.0",
    "attributes": {
      "org.gradle.status": "release"
    }
  },
  "createdBy": {
    "gradle": {
      "version": "9.8.1"
    }
  },
  "variants": [
    ...
    {
      "name": "csvRuntimeElements",
      "attributes": {
        "org.gradle.category": "library",
        "org.gradle.dependency.bundling": "external",
        "org.gradle.jvm.version": 26,
        "org.gradle.libraryelements": "jar",
        "org.gradle.usage": "java-runtime"
      },
      "dependencies": [
        {
          "group": "org.apache.commons",
          "module": "commons-csv",
          "version": {
            "requires": "1.14.1"
          }
        }
      ],
      "files": [
        {
          "name": "tidepool-model-1.0-csv.jar",
          "url": "tidepool-model-1.0-csv.jar",
          "size": 1312,
          ...
        }
      ],
      "capabilities": [
        {
          "group": "io.tidepool",
          "name": "tidepool-model-csv",
          "version": "1.0"
        }
      ]
    },
    ...
    {
      "name": "tideData",
      "attributes": {
        "io.tidepool.flavor": "tides",
        "org.gradle.category": "tidepool-sample",
        "org.gradle.usage": "tidepool-data"
      },
      "files": [
        {
          "name": "tidepool-model-1.0-tides.csv",
          "url": "tidepool-model-1.0-tides.csv",
          "size": 239,
          ...
        }
      ]
    },
    ...
  ]
}

Its variant names are apiElements, runtimeElements, testFixturesApiElements, testFixturesRuntimeElements, csvSourcesElements, csvApiElements, csvRuntimeElements, tideData, harmonicsData.

Mapping to the POM and to ivy.xml

The POM is written for Maven and for old Gradle; it carries what Maven can express, and a marker comment:

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <version>1.0</version>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>compile</scope>
      <optional>true</optional>
    </dependency>
  </dependencies>
</project>

The commons-csv dependency appears with <optional>true</optional>, the feature’s mapping. Everything else (variants, the csv capability, the test fixtures, the data variants) is only in the .module file. The marker is the line do_not_remove: published-with-gradle-metadata; Gradle reads the POM first, sees it, and only then fetches the module file. The manual describes this as a saving of one request in the common case where there is no module file.

The same component published with ivy-publish maps variants to Ivy configurations (the sequence is the stock publishIvyPublicationToLocalIvyRepository; the publication timestamp is the time of the run):

<?xml version="1.0" encoding="UTF-8"?>
<ivy-module version="2.0" xmlns:m="http://ant.apache.org/ivy/maven">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <info organisation="io.tidepool" module="tidepool-model" revision="1.0" status="integration" publication="20261016205549"/>
  <configurations>
    <conf name="compile" visibility="public"/>
    <conf name="csvApiElements" visibility="public"/>
    <conf name="csvRuntimeElements" visibility="public"/>
    <conf name="csvSourcesElements" visibility="public"/>
    <conf name="default" visibility="public" extends="runtime,tideData,harmonicsData"/>
    <conf name="harmonicsData" visibility="public"/>
    <conf name="runtime" visibility="public"/>
    <conf name="testFixturesApiElements" visibility="public"/>
    <conf name="testFixturesRuntimeElements" visibility="public"/>
    <conf name="tideData" visibility="public"/>
  </configurations>
  <publications>
    <artifact name="tidepool-model" type="jar" ext="jar" conf="compile,runtime"/>
    <artifact name="tidepool-model" type="jar" ext="jar" conf="testFixturesApiElements,testFixturesRuntimeElements" m:classifier="test-fixtures"/>
    <artifact name="tidepool-model" type="jar" ext="jar" conf="csvSourcesElements" m:classifier="csv-sources"/>
    <artifact name="tidepool-model" type="jar" ext="jar" conf="csvApiElements,csvRuntimeElements" m:classifier="csv"/>
    <artifact name="tidepool-model" type="csv" ext="csv" conf="tideData" m:classifier="tides"/>
    <artifact name="tidepool-model" type="csv" ext="csv" conf="harmonicsData" m:classifier="harmonics"/>
  </publications>
  <dependencies>
    <dependency org="io.tidepool" name="tidepool-model" rev="1.0" conf="testFixturesApiElements-&gt;default"/>
    <dependency org="io.tidepool" name="tidepool-model" rev="1.0" conf="testFixturesRuntimeElements-&gt;default"/>
    <dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="csvApiElements-&gt;default"/>
    <dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="csvRuntimeElements-&gt;default"/>
  </dependencies>
</ivy-module>

When a build uses something that only GMM can represent, Gradle warns at publication time rather than silently dropping it. For the model’s first publication, before the warnings were silenced in the build file above, the output began

Maven publication 'maven' pom metadata warnings (silence with 'suppressPomMetadataWarningsFor(variant)'):
  - Variant csvApiElements:
      - Declares capability io.tidepool:tidepool-model-csv:1.0 which cannot be mapped to Maven
...
  - Variant testFixturesRuntimeElements:
      - Declares capability io.tidepool:tidepool-model-test-fixtures:1.0 which cannot be mapped to Maven
These issues indicate information that is lost in the published 'pom' metadata file, which may be an issue if the published library is consumed by an old Gradle version or Apache Maven.
The 'module' metadata file, which is used by Gradle 6+ is not affected.

and said the same for the Ivy publication. suppressPomMetadataWarningsFor("csvApiElements") (and the suppress... methods of IvyPublication for Ivy) silences one variant; the build file above silences all five because the information loss is intended.

The manual’s mapping table summarizes what is lost in each format. The constraint and custom-component rows were not run here; the feature row was, with the Ivy difference noted above:

Gradle concept Maven POM ivy.xml

dependency constraints

<dependencyManagement> (not transitive in Maven)

not published

rich version constraints

the requires version

the requires version

component capabilities

not published

not published

feature variants

artifacts uploaded; dependencies published as optional

artifacts uploaded; the manual says the dependencies are not published, but in this run they were, in the feature’s own configurations (see the descriptor below)

custom component types and variants

artifacts uploaded; dependencies as described by the mapping

artifacts uploaded; dependencies ignored

Consuming the published module

A separate consumer build reads the repository. Its settings file adds the repository and, optionally, makes it behave like Maven; its build file asks for the feature and for the test fixtures:

Kotlin DSL (consumer/settings.gradle.kts)
rootProject.name = "consumer"

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            name = "tidepool"
            url = uri("../kts/build/repo")
            // -PpomOnly: behave like Maven, ignore the .module file
            if (providers.gradleProperty("pomOnly").isPresent) {
                metadataSources {
                    mavenPom()
                    ignoreGradleMetadataRedirection()
                }
            }
        }
        mavenCentral()
    }
}
Groovy DSL (consumer/settings.gradle)
rootProject.name = 'consumer'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            name = 'tidepool'
            url = '../groovy/build/repo'
            // -PpomOnly: behave like Maven, ignore the .module file
            if (providers.gradleProperty('pomOnly').present) {
                metadataSources {
                    mavenPom()
                    ignoreGradleMetadataRedirection()
                }
            }
        }
        mavenCentral()
    }
}
Kotlin DSL (consumer/build.gradle.kts)
plugins {
    java
}

val withCsv = providers.gradleProperty("csv").map { it.toBoolean() }.getOrElse(true)

dependencies {
    implementation("io.tidepool:tidepool-model:1.0") {
        if (withCsv) {
            capabilities {
                requireFeature("csv")
            }
        }
    }
    testImplementation(testFixtures("io.tidepool:tidepool-model:1.0"))
}
Groovy DSL (consumer/build.gradle)
plugins {
    id 'java'
}

def withCsv = providers.gradleProperty('csv').map { it.toBoolean() }.getOrElse(true)

dependencies {
    implementation('io.tidepool:tidepool-model:1.0') {
        if (withCsv) {
            capabilities {
                requireFeature('csv')
            }
        }
    }
    testImplementation testFixtures('io.tidepool:tidepool-model:1.0')
}

Resolved from the module file (the default), with and without the feature:

$ ./gradlew -q dependencies --configuration runtimeClasspath -Pcsv=true
runtimeClasspath - Runtime classpath of source set 'main'.
\--- io.tidepool:tidepool-model:1.0
     \--- org.apache.commons:commons-csv:1.14.1
          +--- commons-io:commons-io:2.20.0
          \--- commons-codec:commons-codec:1.19.0
$ ./gradlew -q dependencies --configuration runtimeClasspath -Pcsv=false
runtimeClasspath - Runtime classpath of source set 'main'.
\--- io.tidepool:tidepool-model:1.0

Resolved from the POM only (-PpomOnly, which sets ignoreGradleMetadataRedirection()), the consumer sees what a Maven consumer sees: the optional dependency is not followed, and the feature cannot be requested at all, because the POM has no capabilities. The variants in the message are the ones Gradle derives from a POM (compile and runtime, javadoc and sources, and four platform variants):

$ ./gradlew -q dependencies --configuration runtimeClasspath -PpomOnly -Pcsv=false
runtimeClasspath - Runtime classpath of source set 'main'.
\--- io.tidepool:tidepool-model:1.0
$ ./gradlew -q dependencyInsight --configuration runtimeClasspath --dependency tidepool-model -PpomOnly
io.tidepool:tidepool-model:1.0 FAILED
   Failures:
      - Could not resolve io.tidepool:tidepool-model:1.0.
          - Unable to find a variant of 'io.tidepool:tidepool-model:1.0' with the requested capability: feature 'csv':
               - Variant 'compile' provides 'io.tidepool:tidepool-model:1.0'
               - Variant 'enforced-platform-compile' provides 'io.tidepool:tidepool-model-derived-enforced-platform:1.0'
               - Variant 'enforced-platform-runtime' provides 'io.tidepool:tidepool-model-derived-enforced-platform:1.0'
               - Variant 'javadoc' provides 'io.tidepool:tidepool-model:1.0'
               - Variant 'platform-compile' provides 'io.tidepool:tidepool-model-derived-platform:1.0'
               - Variant 'platform-runtime' provides 'io.tidepool:tidepool-model-derived-platform:1.0'
               - Variant 'runtime' provides 'io.tidepool:tidepool-model:1.0'
               - Variant 'sources' provides 'io.tidepool:tidepool-model:1.0'
io.tidepool:tidepool-model:1.0 FAILED
\--- runtimeClasspath

metadataSources { gradleMetadata() } on a repository goes the other way and tells Gradle to look only for the module file, which saves a request per module for repositories known to be published by Gradle. mavenPom(), ivyDescriptor() and artifact() select the other sources.

Disabling GMM publication

Disabling is a build-file decision, made by disabling the task that generates the file. It is for repositories that reject the unknown file, or for builds that use Maven- or Ivy-specific features GMM cannot represent:

Kotlin DSL (excerpt of tidepool-model/build.gradle.kts, selected by -PnoGmm)
if (providers.gradleProperty("noGmm").isPresent) {
    tasks.withType<GenerateModuleMetadata>().configureEach {
        enabled = false
    }
}
Groovy DSL (excerpt of tidepool-model/build.gradle)
if (providers.gradleProperty('noGmm').present) {
    tasks.withType(GenerateModuleMetadata).configureEach {
        enabled = false
    }
}
$ ./gradlew --console=plain --configuration-cache :tidepool-model:publishMavenPublicationToLocalRepository -PnoGmm
> Task :tidepool-model:generateMetadataFileForMavenPublication SKIPPED
> Task :tidepool-model:publishMavenPublicationToLocalRepository
BUILD SUCCESSFUL in 558ms
$ find build/repo -type f ! -name "*.md5" ! -name "*.sha*" | sort
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-csv-sources.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-csv.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-harmonics.csv
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-test-fixtures.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0-tides.csv
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0.jar
build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0.pom
build/repo/io/tidepool/tidepool-model/maven-metadata.xml
$ grep -c do_not_remove build/repo/io/tidepool/tidepool-model/1.0/tidepool-model-1.0.pom
0

No .module file, and the POM lost its marker comment (grep -c do_not_remove prints 0), so Gradle consumers see a plain Maven module again: no feature variants, no test-fixtures variants (their jars are there, as classified artifacts), no data variants. The downstream cost is real; disable GMM only when you must.

What changed from Gradle 8.14 and from the Gradle 1.x book

  • Gradle 8.14: variant-aware resolution, GMM publication by default, feature variants and test fixtures are the same (not run here). The explicit configuration roles API (consumable, resolvable, dependencyScope) exists since 8.4. Gradle 9 deprecates passing a Project as a dependency notation (9.6 in this run) and prints the deprecation with a stack trace location under --warning-mode all.

  • Gradle in Action covers Gradle 1.7. There a configuration was the only unit a dependency had: compile, runtime, testCompile and custom ones, selected by name (project(path: ':a', configuration: 'x')). Since Gradle 7.0 the compile, runtime and testCompile configurations are gone; implementation, api, compileOnly and runtimeOnly are declaration buckets and the consumer selects an outgoing variant by attributes. The book also predates Gradle Module Metadata (5.3), feature variants, test fixtures (5.6) and artifact transforms; what Maven calls an optional dependency is a feature variant now, and the test fixtures plugin is the supported way to share test helpers.

  • Gradle 10 is announced but not released; nothing on this page depends on it.

The same idea in the other two tools

Concept Gradle Maven Ant with Ivy

Component metadata

.module (GMM) plus POM or ivy.xml

pom.xml

ivy.xml with configurations

Compile versus runtime

java-api and java-runtime usage on apiElements and runtimeElements

dependency scope (compile, runtime, …​)

configurations such as compile and runtime, mapped with conf="a->b"

Optional parts

feature variants with a capability

<optional>true</optional>, untyped

an extra configuration

Same artifact, different shape

artifact transforms

classifier or type on the dependency (sources, test-jar)

artifact type and ext

Test helpers shared across projects

test fixtures plugin

test-jar with <type>test-jar</type>

a configuration for the test jar

Classic mistakes

  • Declaring dependencies on a resolvable or consumable configuration (Asking for sample data): use a dependencyScope bucket and extendsFrom.

  • Expecting a custom attribute to exclude variants that do not set it: it does not (A variant that does not say anything is compatible); add a distinguishing category or usage.

  • Registering an artifact transform from a Kotlin build script (a non-static inner class), or reading the transformed files in doLast without declaring them as task inputs (Artifact transforms).

  • Creating a custom consumable configuration and expecting it in the publication: add it to the component with addVariantsFromConfiguration, and give its artifacts distinct classifiers.

  • Using def helpers in a Groovy build script that read script-level variables: use closures.

  • Publishing a library to a repository that rejects .module files without disabling GMM, or disabling GMM and then wondering why the feature variants are gone.

  • Reading a POM-only view of a Gradle-published module in another tool and concluding that the library "has no optional features": the information is in the module file.