Use Version Catalogs to Centralize Dependency Versions

Version Catalogs provide a centralized, declarative way to manage dependency versions throughout a build.

Explanation

When you define your dependency versions in a single, shared version catalog, you reduce duplication and make upgrades easier. Instead of changing dozens of build.gradle(.kts) files, you update the version in one place. This simplifies maintenance, improves consistency, and reduces the risk of accidental version drift between modules. Consistent version declarations across projects also make it easier to reason about behavior during testing—especially in modular builds where transitive upgrades can silently change runtime behavior in later stages of the build.

However, version catalogs only influence declared versions, not resolved versions. Use them in combination with dependency locking and version alignment to enforce consistency across builds. To influence resolved versions, check out platforms.

Example

Don’t Do This

Avoid declaring versions in project.ext, constants, or local variables:

build.gradle.kts
plugins {
    id("java-library")
    id("com.github.ben-manes.versions").version("0.45.0")
}
val groovyVersion = "3.0.5"

dependencies {
    api("org.codehaus.groovy:groovy:$groovyVersion")
    api("org.codehaus.groovy:groovy-json:$groovyVersion")
    api("org.codehaus.groovy:groovy-nio:$groovyVersion")

    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")

    implementation("org.apache.commons:commons-lang3") {
        version {
            strictly("[3.8, 4.0[")
            prefer("3.9")
        }
    }
}
build.gradle
plugins {
    id('java-library')
    id('com.github.ben-manes.versions').version('0.45.0')
}
def groovyVersion = '3.0.5'

dependencies {
    api("org.codehaus.groovy:groovy:$groovyVersion")
    api("org.codehaus.groovy:groovy-json:$groovyVersion")
    api("org.codehaus.groovy:groovy-nio:$groovyVersion")

    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")

    implementation("org.apache.commons:commons-lang3") {
        version {
            strictly("[3.8, 4.0[")
            prefer("3.9")
        }
    }
}

Avoid misusing version catalogs for unrelated concerns:

  • Don’t use them to store shared strings or non-library constants

  • Don’t overload them with arbitrary logic or plugin-specific configuration

Do This Instead

Use a centralized libs.versions.toml file in your gradle/ directory:

gradle/libs.versions.toml
[versions]
groovy = "3.0.5"
junit-jupiter = "5.10.0"

[libraries]
groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" }
groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" }
groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" }
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = { strictly = "[3.8, 4.0[", prefer = "3.9" } }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }

[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]

[plugins]
versions = { id = "com.github.ben-manes.versions", version = "0.45.0" }
build.gradle.kts
plugins {
    id("java-library")
    alias(libs.plugins.versions)
}
dependencies {
    api(libs.bundles.groovy)
    testImplementation(libs.junit.jupiter)
    implementation(libs.commons.lang3)
}
build.gradle
plugins {
    id('java-library')
    alias(libs.plugins.versions)
}
dependencies {
    api(libs.bundles.groovy)
    testImplementation(libs.junit.jupiter)
    implementation(libs.commons.lang3)
}

Name Version Catalog Entries Appropriately

Consistent and descriptive names in your version catalog enhance readability and maintainability across your build scripts.

Explanation

Version catalogs provide a centralized way to manage dependencies by mapping full dependency coordinates to concise, reusable aliases like airlift-aircompressor. Adopting clear naming conventions for those aliases ensures that developers can easily identify and use dependencies throughout the project.

Aliases are typically made up of 1 to 3 segments. For example org.apache.commons:commons-lang3 could be represented as commonsLang3, apache-commonsLang3, or commons-lang3.

The following guidelines help in naming catalog entries effectively:

  1. Use dashes to separate segments: Prefer hyphen/dashes (-) over underscores (_) to separate different parts of the entry name.

    Example: For org.apache.logging.log4j:log4j-api, use log4j-api

  2. Derive the first segment from the project group: Use a unique identifier from the project’s group ID as the first segment. Do not include the top level domain in the segment (com, org, net, dev).

    Example: For com.fasterxml.jackson.core:jackson-databind, use jackson-databind or jackson-core-databind

  3. Derive the second segment from the artifact ID: Use a unique identifier from the artifact ID as the second segment.

    Example: For com.linecorp.armeria:armeria-grpc, use armeria-grpc

  4. Avoid generic terms in the segments: Exclude terms that are obvious or implied in the context of your project (core, java, gradle, module, sdk), especially if the term appears by itself.

    Example: For com.google.googlejavaformat:google-java-format, use google-java-format, not google-java or java

  5. Omit redundant segments: If the group and artifact IDs are the same, avoid repeating them.

    Example: For io.ktor:ktor-client-core, use ktor-client-core, not ktor-ktor-client-core

  6. Convert internal dashes to camelCase: If the artifact ID contains dashes, convert them to camelCase for better readability in code.

    Example: spring-boot-starter-web becomes springBootStarterWeb

  7. Suffix plugin libraries with -plugin: When referencing a plugin as a library (not in the [plugins] section), append -plugin to the name.

    Example: For org.owasp:dependency-check-gradle, use dependency-check-plugin

Example

gradle/libs.versions.toml
[versions]
slf4j = "2.0.13"
jackson = "2.17.1"
groovy = "3.0.5"
checkstyle = "8.37"
commonsLang = "3.9"

[libraries]
# SLF4J
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }

# Jackson
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-dataformatCsv = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-csv", version.ref = "jackson" }

# Groovy bundle
groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" }
groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" }
groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" }

# Apache Commons Lang
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = { strictly = "[3.8, 4.0[", prefer = "3.9" } }

[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]

[plugins]
versions = { id = "com.github.ben-manes.versions", version = "0.45.0" }
build.gradle.kts
plugins {
    id("java-library")
    alias(libs.plugins.versions)
}

repositories {
    mavenCentral()
}

dependencies {
    // SLF4J
    implementation(libs.slf4j.api)

    // Jackson
    implementation(libs.jackson.databind)
    implementation(libs.jackson.dataformatCsv)

    // Groovy bundle
    api(libs.bundles.groovy)

    // Commons Lang
    implementation(libs.commons.lang3)
}
build.gradle
plugins {
    id 'java-library'
    alias(libs.plugins.versions)
}

repositories {
    mavenCentral()
}

dependencies {
    // SLF4J
    implementation libs.slf4j.api

    // Jackson
    implementation libs.jackson.databind
    implementation libs.jackson.dataformatCsv

    // Groovy bundle
    api libs.bundles.groovy

    // Commons Lang
    implementation libs.commons.lang3
}

Set up your Dependency Repositories in the Settings file

Declare your repositories for your plugins and dependencies in settings.gradle.kts.

Explanation

Using settings.gradle.kts file to declare repositories has several benefits:

  • Avoids repetition: Centralizing repository declarations eliminates the need to repeat them in each project’s build.gradle.kts.

  • Improves debuggability: Ensures all projects resolve dependencies during resolution from the same repositories, in a consistent order.

  • Matches the build model: Repositories are not part of the project definition; they are part of global build logic, so settings is a more appropriate place for them.

While dependencyResolutionManagement.repositories is an incubating API, it is the preferred way of declaring repositories.

Example

Don’t Do This

You could set up repositories in individual build.gradle.kts files with:

build.gradle.kts
buildscript {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

plugins {
    id("java")
}

repositories {
    mavenCentral()
}
build.gradle
buildscript {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

plugins {
    id("java")
}

repositories {
    mavenCentral()
}

Do This Instead

Instead, you should set them up in settings.gradle.kts like this:

settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}
settings.gradle
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

Don’t Explicitly Depend on the Kotlin Standard Library

The Kotlin Gradle Plugin automatically adds a dependency on the Kotlin standard library (stdlib) to each source set, so there is no need to declare it explicitly.

Explanation

The version of the standard library added is the same as the version of the Kotlin Gradle Plugin applied to the project. If your build does not require a specific or different version of the standard library, you should avoid adding it manually.

Setting the kotlin.stdlib.default.dependency property to false prevents the Kotlin plugin from automatically adding the Kotlin standard library dependency to your project. This can be useful in specific scenarios, such as when you want to manage the Kotlin standard library dependency version manually.

Example

Don’t Do This

build.gradle.kts
plugins {
    kotlin("jvm").version("2.4.21")
}

dependencies {
    api(kotlin("stdlib")) (1)
}
build.gradle
plugins {
    id("org.jetbrains.kotlin.jvm") version "2.4.21"
}

dependencies {
    api("org.jetbrains.kotlin:kotlin-stdlib:2.4.21") (1)
}
1 stdlib is explicitly depended upon: This project contains an implicit dependency on the Kotlin standard library, which is required to compile its source code.

Do This Instead

build.gradle.kts
plugins {
    kotlin("jvm").version("2.4.21") (1)
}
build.gradle
plugins {
    id("org.jetbrains.kotlin.jvm") version "2.4.21"  (1)
}
1 stdlib dependency is not included explicitly: The standard library remains available for use, and source code requiring it can be compiled without any issues.

Avoid Redundant Dependency Declarations

Avoid declaring the same dependency multiple times, especially when it is already available transitively or through another configuration.

Explanation

Duplicating dependencies in Gradle build scripts can lead to:

  • Increased maintenance: Declaring a dependency in multiple places makes it harder to manage.

  • Unexpected behavior: Declaring the same dependency in multiple configurations (e.g., compileOnly and implementation) can result in hard-to-diagnose classpath issues.

Example

Don’t Do This

build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.0") (1)
}
build.gradle
plugins {
    id 'java-library'
}

dependencies {
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.0") (1)
}
1 Redundant dependency in implementation scope.

Do This Instead

build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.0") (1)
}
build.gradle
plugins {
    id 'java-library'
}

dependencies {
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.0") (1)
}
1 Declare dependency once

Declare Dependencies using a single GAV (group:artifact:version) String

When declaring dependencies without a version catalog, prefer using the single GAV string notation implementation("org.example:library:1.0"). Avoid using the named argument notation. The named argument notation has been deprecated and will no longer be supported starting in Gradle 10.

Explanation

All of these declarations will be treated equivalently when Gradle resolves dependencies. However, the single-string form is more concise, easier to read, and is widely adopted in the broader JVM ecosystem.

This format is also recommended by Maven Central in its documentation and usage examples, making it the most familiar and consistent style for developers across tools.

Example

Don’t Do This

build.gradle.kts
dependencies {
    implementation(group = "com.fasterxml.jackson.core", name = "jackson-databind", version = "32.17.0")  (1)
    api(group = "com.google.guava", name = "guava", version = "32.1.2-jre") {
        exclude(group = "com.google.code.findbugs", module = "jsr305")  (2)
    }
}
build.gradle
dependencies {
    implementation(group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.17.0') (1)
    api(group: 'com.google.guava', name: 'guava', version: '32.1.2-jre') {
        exclude(group: 'com.google.code.findbugs', module: 'jsr305')    (2)
    }
}
1 Avoid the named argument notation when declaring dependencies
2 Other modifiers methods and constraints like exclude are not included in this recommendation and can use named argument notation as needed

Do This Instead

build.gradle.kts
dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0") (1)
    api("com.google.guava:guava:32.1.2-jre") {
        exclude(group = "com.google.code.findbugs", module = "jsr305")  (2)
    }
}
build.gradle
dependencies {
    implementation('com.fasterxml.jackson.core:jackson-databind:2.17.0') (1)
    api('com.google.guava:guava:32.1.2-jre') {
        exclude(group: 'com.google.code.findbugs', module: 'jsr305')    (2)
    }
}
1 Use the string notation instead when declaring dependencies
2 Other modifiers methods and constraints like exclude are not included in this recommendation and can use named argument notation as needed

Use Content Filtering with multiple Repositories

When using multiple repositories in a build, use repository content filtering to ensure that dependencies are resolved from an appropriate repository.

Explanation

If your build declares more than one repository, you should declare content filters on these repositories to ensure you search for and obtain dependencies from the correct place.

Content filtering is necessary if you have a reason to restrict searching for a dependency to a particular repository, and can be a good idea even if acceptable dependency artifacts exist in multiple locations.

When possible, you should use the exclusiveContent feature to restrict dependencies to a particular known repository.

Content filtering has three main benefits:

  1. Performance, since you only query repositories for dependencies that should actually exist within them

  2. Security, by avoiding asking potentially every repository for every dependency (even ones they shouldn’t contain), you improve resiliency to supply chain attacks by avoiding leaking information about your dependencies to other repositories, or even downloading potentially malicious artifacts

  3. Reliability, by avoiding searching repositories that contain invalid or incorrect metadata for particular dependencies, which could result in obtaining incorrect transitive dependencies

Repositories will be searched for dependencies that pass their filters in the order they are declared. Often the last repository is declared without any filters in order to serve as a default fallback repository that is queried for any dependencies that don’t pass the filters present on the other repositories.

Carefully consider using content filtering with a fallback repository. This can pose a security risk, so make sure you fully trust the fallback repository. This setup can result in inadvertently (and silently) resolving dependencies from the fallback repository that were intended to come from filtered repositories if the dependencies were not available in those repositories.

Example

Don’t Do This

Don’t add multiple repositories without content filtering:

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        mavenCentral()
        google()
    }
}
settings.gradle
dependencyResolutionManagement {
    repositories {
        mavenCentral()
        google()
    }
}

Do This Instead

Use content filtering to ensure that the proper repositories are searched first for the expected artifacts:

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google {
            content {
                // Use this repository for androidx and GMS dependencies
                includeGroupByRegex("androidx.*")
                includeGroup("com.google.gms")
            }
        }
        // Specify the fallback repository last
        mavenCentral()
    }
}
settings.gradle
dependencyResolutionManagement {
    repositories {
        google {
            content {
                // Use this repository for androidx and GMS dependencies
                includeGroupByRegex("androidx.*")
                includeGroup("com.google.gms")
            }
        }
        // Specify the fallback repository last
        mavenCentral()
    }
}

In many cases, it is better to use exclusive content filtering, as it ensures that dependencies can only be found in the expected repository. If they are not present there, they will not be found at all.

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        exclusiveContent {
            forRepository {
                google()
            }
            filter {
                // Only use this repository, and use this repository only, for androidx and GMS dependencies
                includeGroupByRegex("androidx.*")
                includeGroup("com.google.gms")
            }
        }
        // Specify the fallback repository last
        mavenCentral()
    }
}
settings.gradle
dependencyResolutionManagement {
    repositories {
        exclusiveContent {
            forRepository {
                google()
            }
            filter {
                // Only use this repository, and use this repository only, for androidx and GMS dependencies
                includeGroupByRegex("androidx.*")
                includeGroup("com.google.gms")
            }
        }
        // Specify the fallback repository last
        mavenCentral()
    }
}

Apply Exclusions Narrowly

When excluding transitive dependencies, apply exclusions as narrowly as possible.

Explanation

Sometimes you may need to exclude transitive dependencies that cause conflicts or issues in your project.

Exclusions can negatively affect dependency resolution performance. Applying exclusions as narrowly as possible minimizes this impact. It also reduces the risks of inadvertently and silently excluding dependencies that are required elsewhere in your build, and of accidental runtime dependency clashes.

Gradle offers several ways to exclude transitive dependencies. When excluding transitive dependencies, keep the scope as narrow as possible:

  • Attach exclusions to specific dependencies rather than applying them to an entire configuration.

  • Exclude a single module from a group, instead of excluding the entire group.

  • Avoid global exclusions using configurations.all { …​ } or configurations.configureEach { …​ }.

Example

Don’t Do This

build.gradle.kts
dependencies {
    implementation("org.apache.commons:commons-pool2:2.12.1") (1)
    implementation("org.hibernate:hibernate-core:3.6.10.Final")
    // ... other dependencies ...
}

configurations {
    "implementation" {
        exclude(group = "cglib") (2)
    }

    "implementation" {
        exclude(group = "org.ow2.asm", module = "asm-util") (3)
    }
}

configurations.configureEach {
    exclude(group = "javassist", module = "javassist") (4)
}
build.gradle
dependencies {
    implementation("org.apache.commons:commons-pool2:2.12.1") (1)
    implementation("org.hibernate:hibernate-core:3.6.10.Final")
    // ... other dependencies ...
}

configurations {
    implementation {
        exclude(group: "cglib") (2)
    }

    implementation {
        exclude(group: "org.ow2.asm", module: "asm-util") (3)
    }
}

configurations.configureEach {
    exclude(group: "javassist", module: "javassist") (4)
}
1 The commons-pool2 dependency transitively includes cglib:cglib and org.ow2.asm:asm-util as optional dependencies - we want to exclude both. hibernate-core transitively optionally includes cglib:cglib, and also javaassist:javassist - we want to exclude both.
2 This excludes every module provided by the cglib group from every dependency in the implementation configuration. If other current or future dependencies in this project rely on different modules from cglib, those dependencies may fail to resolve, leading to compilation or runtime errors.
3 This excludes org.ow2.asm:asm-util from every dependency in the implementation configuration. If future dependencies rely on org.ow2.asm:asm-util, they may fail at compile or runtime because the module will be silently excluded.
4 This excludes javaassist:javassist from all dependencies in all configurations, including those added by plugins or in the future, which carries the same risks as above, but on a larger scale.

Do This Instead

Exclude transitive dependencies as narrowly as possible, ideally on individual dependencies:

build.gradle.kts
dependencies {
    implementation("org.apache.commons:commons-pool2:2.12.1") { (1)
        exclude(group = "cglib", module = "cglib") (2)
        exclude(group = "org.ow2.asm", module = "asm-util")

    }
    implementation("org.hibernate:hibernate-core:3.6.10.Final") {
        exclude(group = "cglib", module = "cglib")
        exclude(group = "javassist", module = "javassist") (3)
    }
    // ... other dependencies ...
}
build.gradle
dependencies {
    implementation("org.apache.commons:commons-pool2:2.12.1") { (1)
        exclude(group: "cglib", module: "cglib") (2)
        exclude(group: "org.ow2.asm", module: "asm-util")

    }
    implementation("org.hibernate:hibernate-core:3.6.10.Final") {
        exclude(group: "cglib", module: "cglib")
        exclude(group: "javassist", module: "javassist") (3)
    }
    // ... other dependencies ...
}
1 Exclusions are applied only to the dependency that actually transitively includes them.
2 All exclusions apply to a particular module instead of every module from a particular group.
3 javaassist:javassist is only excluded from the hibernate-core dependency - the only dependency that transitively includes it.

Though it may seem repetitive to exclude the same transitive dependencies from multiple dependencies, this approach is safer, more performant, less likely to cause accidental runtime crashes, and makes it clearer which dependencies are affected by each exclusion.

Always Declare Attributes on Consumable and Resolvable Configurations

When creating custom configurations that are meant to be consumed or resolved, always declare at least one attribute on them.

Explanation

Gradle uses variant-aware dependency resolution to select the appropriate variants of a project. Attributes serve as the primary matching mechanism between resolvable configurations (what a consumer needs) and consumable configurations (what a producer provides as variants).

Even if a producer project has only one consumable configuration, omitting attributes will cause variant-aware resolution to fail with the following error:

Unable to find a matching variant of project :producer:
  - No variants exist.

While Gradle can resolve configurations by selecting them directly by name, this approach is outdated:

  • It was deprecated for Maven repositories in Gradle 8.10 and removed in Gradle 9.0.0. It is only necessary for use with Ivy repositories. This mechanism can still be used for local project dependencies, but should be avoided.

  • Configuration names should be treated as internal implementation details. Relying on them for resolution forces the consumer to know too much about the producer’s internal structure.

When adding attributes to a configuration, common attributes to use include:

  • Category.CATEGORY_ATTRIBUTE - Indicates the category of the component (e.g., LIBRARY, DOCUMENTATION)

  • For JVM Projects:

    • Usage.USAGE_ATTRIBUTE - Indicates which classpath this variant represents (e.g., JAVA_RUNTIME, JAVA_API)

    • LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE - Indicates the packaging of the classes (e.g., JAR, CLASSES)

While attributes are not yet strictly required for all resolvable configurations, omitting them leads to fragile builds. Declaring sensible attributes ensures your build remains reliable as it scales and evolves.

Example

Don’t Do This

Avoid creating consumable and resolvable configurations without attributes:

In producer/build.gradle(.kts):

producer/build.gradle.kts
configurations.consumable("customElements") (1)

val generateFile = tasks.register("generateFile") {
    val outputFile = layout.buildDirectory.file("custom/output.txt")
    outputs.file(outputFile)
    doLast {
        outputFile.get().asFile.writeText("Custom output from producer")
    }
}

artifacts {
    add("customElements", generateFile)
}
producer/build.gradle
configurations.consumable('customElements') (1)

def generateFile = tasks.register('generateFile') {
    def outputFile = layout.buildDirectory.file('custom/output.txt')
    outputs.file(outputFile)
    doLast {
        outputFile.get().asFile.text = 'Custom output from producer'
    }
}

artifacts {
    customElements generateFile
}
1 No attributes declared on the configuration in the producer build: This consumable configuration lacks attributes to describe what it provides.

In consumer/build.gradle(.kts):

consumer/build.gradle.kts
val customElementsDependencies = configurations.dependencyScope("customElementsDependencies")

dependencies {
    customElementsDependencies(project(path = ":producer", configuration = "customElements"))
}

val customElements = configurations.resolvable("customElements") { (1)
    extendsFrom(customElementsDependencies.get())
}

tasks.register("resolveCustom") {
    inputs.files(customElements.get())
    doLast {
        inputs.files.forEach { file: File ->
            logger.lifecycle("Resolved: ${file.name}")
        }
    }
}
consumer/build.gradle
def customElementsDependenciesProvider = configurations.dependencyScope('customElementsDependencies')

dependencies {
    customElementsDependencies(project(path: ':producer', configuration: 'customElements'))
}

def customElements = configurations.resolvable('customElements') { (1)
    extendsFrom(customElementsDependenciesProvider.get())
}

tasks.register('resolveCustom') {
    inputs.files(customElements)
    doLast {
        inputs.files.each { file ->
            logger.lifecycle("Resolved: ${file.name}")
        }
    }
}
1 No attributes declared on the configuration in the consumer build: This resolvable configuration lacks attributes to describe what it needs.

This approach works only because we explicitly name the configuration in the dependency declaration (configuration = "customElements"). Resolving configurations by name should be avoided.

Do This Instead

Always declare attributes on consumable and resolvable configurations:

In producer/build.gradle(.kts):

producer/build.gradle.kts
val CUSTOM_ATTRIBUTE = Attribute.of("custom", String::class.java) (1)
dependencies.attributesSchema.attribute(CUSTOM_ATTRIBUTE)

val generateFile = tasks.register("generateFile") {
    val outputFile = layout.buildDirectory.file("custom/output.txt")
    outputs.file(outputFile)
    doLast {
        outputFile.get().asFile.writeText("Custom output from producer")
    }
}

configurations {
    consumable("customElements") {
        attributes { (2)
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
            attribute(CUSTOM_ATTRIBUTE, "my-custom-value")
        }
        outgoing {
            artifact(generateFile)
        }
    }
}
producer/build.gradle
def CUSTOM_ATTRIBUTE = Attribute.of("custom", String) (1)
dependencies.attributesSchema.attribute(CUSTOM_ATTRIBUTE)

def generateFile = tasks.register('generateFile') {
    def outputFile = layout.buildDirectory.file('custom/output.txt')
    outputs.file(outputFile)
    doLast {
        outputFile.get().asFile.text = 'Custom output from producer'
    }
}

configurations {
    consumable('customElements') {
        attributes { (2)
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
            attribute(CUSTOM_ATTRIBUTE, "my-custom-value")
        }
        outgoing {
            artifact(generateFile)
        }
    }
}
1 New Attribute type defined in producer: The consumable configuration defines a new custom attribute that contains project-specific variant identification information. The new Attribute is also added to the Gradle Attribute schema to ensure type-safety.
2 Attributes declared in producer: The consumable configuration adds Category = LIBRARY, and custom attribute = my-custom-value. These attributes will be used to identify this variant during resolution.

In consumer/build.gradle(.kts):

consumer/build.gradle.kts
val customElementsDependencies = configurations.dependencyScope("customElementsDependencies")

dependencies {
    customElementsDependencies(project(":producer")) (1)
}

val CUSTOM_ATTRIBUTE = Attribute.of("custom", String::class.java) (2)
dependencies.attributesSchema.attribute(CUSTOM_ATTRIBUTE)

val customElements = configurations.resolvable("customElements") {
    extendsFrom(customElementsDependencies.get())
    attributes { (3)
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
        attribute(CUSTOM_ATTRIBUTE, "my-custom-value")
    }
}

tasks.register("resolveCustom") {
    inputs.files(customElements.get())
    doLast {
        inputs.files.forEach { file: File ->
            logger.lifecycle("Resolved: ${file.name}")
        }
    }
}
consumer/build.gradle
def customElementsDependenciesProvider = configurations.dependencyScope('customElementsDependencies')

dependencies {
    customElementsDependencies(project(':producer')) (1)
}

def CUSTOM_ATTRIBUTE = Attribute.of("custom", String) (2)
dependencies.attributesSchema.attribute(CUSTOM_ATTRIBUTE)


def customElements = configurations.resolvable('customElements') {
    extendsFrom(customElementsDependenciesProvider.get())
    attributes { (3)
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
        attribute(CUSTOM_ATTRIBUTE, "my-custom-value")
    }
}


tasks.register('resolveCustom') {
    inputs.files(customElements)
    doLast {
        inputs.files.each { file ->
            logger.lifecycle("Resolved: ${file.name}")
        }
    }
}
1 The project dependency does not use configuration name: It is no longer necessary to specify the configuration name in the dependency declaration, as variant-aware dependency resolution will be used to select the correct variant.
2 New Attribute type defined in consumer: The resolvable configuration defines the same new custom attribute that contains project-specific variant identification information.
3 Attributes declared in consumer: The resolvable configuration is identified as Category = LIBRARY, and custom attribute = my-custom-value.

By declaring compatible attributes, you decouple your projects from internal naming conventions, resulting in a more robust and maintainable build.

Favor String Values for Custom Attributes

When declaring a custom attribute, use String as its value type. Use Boolean or Integer when the attribute models a flag or a quantity. Avoid using Named types.

Explanation

Gradle accepts four kinds of attribute value type: String, Boolean, any subtype of Number, and any type implementing Named. The first three are plain data, though only Integer among the numeric types can be published. Gradle’s own attributes, such as Usage and LibraryElements, use Named interfaces, which makes Named look like the idiomatic choice for your own attributes. It is not.

A Named value is converted to a plain string whenever it leaves the build. It is published to Gradle Module Metadata, and reported in resolution results, only as this string. A Named type adds machinery on top of that string without changing what the string does:

  • It does not restrict the set of legal values. Attribute matching compares values by name, and objects.named accepts any string you hand it. A Named type is not an enumeration of permitted values, so it buys no validation that String does not already give you.

  • It does not survive publication. A Named value is written to Gradle Module Metadata as its name alone. Consumers of your published module match against that string; the type exists only inside the build that declared it.

  • It requires an ObjectFactory. Values must be built with objects.named(…​) rather than written literally, and reading a value back means calling getName() to recover the string you wanted in the first place.

  • It has to live somewhere shared. A String attribute needs no type declaration at all. A Named type must be on a classpath that every build using the attribute can see.

Example

Don’t Do This

build.gradle.kts
interface MyFormat : Named (1)

val MY_FORMAT = Attribute.of("my-format", MyFormat::class.java)
dependencies.attributesSchema.attribute(MY_FORMAT)

val myElements = configurations.consumable("myElements") {
    attributes {
        attribute(MY_FORMAT, objects.named(MyFormat::class.java, "json")) (2)
    }
}

tasks.register("printFormat") {
    val format = myElements.get().attributes.getAttribute(MY_FORMAT)!!.name (3)
    doLast {
        logger.lifecycle("my-format = $format")
    }
}
build.gradle
interface MyFormat extends Named {} (1)

def MY_FORMAT = Attribute.of("my-format", MyFormat)
dependencies.attributesSchema.attribute(MY_FORMAT)

def myElements = configurations.consumable("myElements") {
    attributes {
        attribute(MY_FORMAT, objects.named(MyFormat, "json")) (2)
    }
}

tasks.register("printFormat") {
    def format = myElements.get().attributes.getAttribute(MY_FORMAT).name (3)
    doLast {
        logger.lifecycle("my-format = $format")
    }
}
1 A type that exists only to hold a name: MyFormat declares no members. Every use of it will reduce to the string it wraps.
2 Setting the value requires ObjectFactory: the string "json" is still the payload, now built through objects.named(…​).
3 Reading the value requires getName(): recovering the string means unwrapping the value that was wrapped on the way in.

Do This Instead

build.gradle.kts
val MY_FORMAT = Attribute.of("my-format", String::class.java) (1)
dependencies.attributesSchema.attribute(MY_FORMAT)

val myElements = configurations.consumable("myElements") {
    attributes {
        attribute(MY_FORMAT, "json") (2)
    }
}

tasks.register("printFormat") {
    val format = myElements.get().attributes.getAttribute(MY_FORMAT)!! (3)
    doLast {
        logger.lifecycle("my-format = $format")
    }
}
build.gradle
def MY_FORMAT = Attribute.of("my-format", String) (1)
dependencies.attributesSchema.attribute(MY_FORMAT)

def myElements = configurations.consumable("myElements") {
    attributes {
        attribute(MY_FORMAT, "json") (2)
    }
}

tasks.register("printFormat") {
    def format = myElements.get().attributes.getAttribute(MY_FORMAT) (3)
    doLast {
        logger.lifecycle("my-format = $format")
    }
}
1 No type to declare: the attribute is typed with String directly.
2 The value is written literally: no ObjectFactory is involved.
3 The value is read as-is: no unwrapping step.

Both builds declare the same attribute, set the same value, and print my-format = json. The String version does it without a supporting type.

Enums Are Not an Exception

An enum implementing Named is the most tempting reason to reach for a Named type, because putting state and behavior on the enum lets one type serve both as the value matched during dependency resolution and as the thing that value names. It can be made to work, and it avoids the ObjectFactory ceremony, since an enum constant is already an instance.

It is still not an exception to the recommendation above. Only the name crosses the publication boundary, so any member you add is reachable solely inside the build that declares the enum. This makes this approach suitable only for local use within a single build.

build.gradle.kts
enum class MyFormat(val extension: String) : Named { (1)
    TYPE_A("txt-a"),
    TYPE_B("txt-b");

    override fun getName(): String = name (2)
}

val MY_FORMAT = Attribute.of("my-format", MyFormat::class.java)
dependencies.attributesSchema.attribute(MY_FORMAT)

configurations.named("apiElements") {
    attributes {
        attribute(MY_FORMAT, MyFormat.TYPE_A) (3)
    }
}

val generateMetadata =
    tasks.named<GenerateModuleMetadata>("generateMetadataFileForMavenPublication")

tasks.register("showPublishedFormat") {
    val moduleFile = generateMetadata.flatMap { it.outputFile }
    inputs.file(moduleFile)
    val inBuild = MyFormat.TYPE_A.extension (4)
    doLast {
        logger.lifecycle("in this build:  MyFormat.TYPE_A.extension = $inBuild")
        moduleFile.get().asFile.readLines()
            .filter { it.contains("my-format") }
            .forEach { logger.lifecycle("in module.json: ${it.trim().removeSuffix(",")}") } (5)
    }
}
build.gradle
enum MyFormat implements Named { (1)
    TYPE_A("txt-a"),
    TYPE_B("txt-b")

    private final String extension

    MyFormat(String extension) {
        this.extension = extension
    }

    @Override
    String getName() { return name() } (2)

    String getExtension() { return extension }
}

def MY_FORMAT = Attribute.of("my-format", MyFormat)
dependencies.attributesSchema.attribute(MY_FORMAT)

configurations.named("apiElements") {
    attributes {
        attribute(MY_FORMAT, MyFormat.TYPE_A) (3)
    }
}

def generateMetadata = tasks.named("generateMetadataFileForMavenPublication")

tasks.register("showPublishedFormat") {
    def moduleFile = generateMetadata.flatMap { it.outputFile }
    inputs.file(moduleFile)
    def inBuild = MyFormat.TYPE_A.extension (4)
    doLast {
        logger.lifecycle("in this build:  MyFormat.TYPE_A.extension = $inBuild")
        moduleFile.get().asFile.readLines()
            .findAll { it.contains("my-format") }
            .each { logger.lifecycle("in module.json: ${it.trim().replaceAll(/,$/, '')}") } (5)
    }
}
1 Extra state on the attribute value type: MyFormat carries an extension that resolution knows nothing about.
2 Boilerplate Named implementation: getName() can only sensibly delegate to the enum’s own name().
3 The enum constant is used as the attribute value: no ObjectFactory is needed here, since the constant is already an instance.
4 The extra state, read inside the producing build: getExtension() resolves to txt-a.
5 The same attribute, read from the generated metadata: the published value is "my-format": "TYPE_A" — a bare string.

extension is absent from the published module, as is every other member of MyFormat. A consumer matches on "TYPE_A" and can only reach getExtension() if it happens to have its own copy of the enum type on its classpath to look the constant back up by name. Within a single build that constraint may be acceptable; across a publication boundary the extra members are simply not there, so none of that behavior can be part of the contract you publish.

Mutable State on an Enum is Silently Dropped

The example above keeps extension final, so its value is fixed by the enum’s own initializers and is reconstructed wherever the constant is. A mutable field assigned per constant during configuration behaves far worse, because the Configuration Cache stores an enum constant as a reference to its class and position, not as an object with fields.

build.gradle.kts
enum class MyFormat : Named {
    TYPE_A, TYPE_B;

    var extension: String = "unset" (1)

    override fun getName(): String = name
}

MyFormat.TYPE_A.extension = "txt-a" (2)

val MY_FORMAT = Attribute.of("my-format", MyFormat::class.java)
dependencies.attributesSchema.attribute(MY_FORMAT)

val myElements = configurations.consumable("myElements") {
    attributes {
        attribute(MY_FORMAT, MyFormat.TYPE_A)
    }
}

tasks.register("showExtension") {
    val value = myElements.get().attributes.getAttribute(MY_FORMAT)!! (3)
    doLast {
        logger.lifecycle("attr value = ${value.name}, extension = ${value.extension}")
    }
}
build.gradle
enum MyFormat implements Named {
    TYPE_A, TYPE_B

    String extension = "unset" (1)

    @Override
    String getName() { return name() }
}

MyFormat.TYPE_A.extension = "txt-a" (2)

def MY_FORMAT = Attribute.of("my-format", MyFormat)
dependencies.attributesSchema.attribute(MY_FORMAT)

def myElements = configurations.consumable("myElements") {
    attributes {
        attribute(MY_FORMAT, MyFormat.TYPE_A)
    }
}

tasks.register("showExtension") {
    def value = myElements.get().attributes.getAttribute(MY_FORMAT) (3)
    doLast {
        logger.lifecycle("attr value = ${value.name}, extension = ${value.extension}")
    }
}
1 A mutable field, defaulted by the enum’s own initializer.
2 Assigned during the configuration phase, not by the initializer.
3 The attribute value is read back and the enum constant captured by the task for use during task execution.

With the Configuration Cache enabled, the field’s value at execution time depends on whether the daemon that serves the build still happens to have the enum class loaded:

$ gradle --configuration-cache showExtension                   # cache miss: configuration runs
attr value = TYPE_A, extension = txt-a

$ gradle --configuration-cache showExtension                   # cache hit, same daemon
attr value = TYPE_A, extension = txt-a

$ gradle --stop && gradle --configuration-cache showExtension   # cache hit, fresh daemon
attr value = TYPE_A, extension = unset

On a cache hit the configuration phase does not run, so the assignment never happens again. A warm daemon hides this, because the mutated constant is still in memory and the stored reference resolves straight back to it. A cold daemon re-initializes the enum class and the field reverts to unset.

The result is a build whose behavior changes based on daemon lifetime — the hardest class of bug to reproduce. Nothing warns you, because from Gradle’s point of view the value round-tripped perfectly: the same constant comes back, and its fields were never part of what was stored. This applies to any enum a task captures, and an attribute value is an easy way for one to end up there.

Mutable state on an enum used as an attribute value is therefore never safe, and immutable state is usable only inside the declaring build. If you accept those limits deliberately, a Named enum can work.

The simpler default is to keep the two responsibilities separate: declare an attribute as a String, or as a Boolean or Integer for flags and quantities. If your build also needs a type that maps names to behavior, let that type exist on its own terms rather than as the attribute’s value type.