A common pattern, in multi-project builds, is that one project consumes the artifacts of another project.

In general, the simplest consumption form in the Java ecosystem is that when A depends on B, then A would depend on the jar produced by project B.

Considerations and possible solutions

A frequent anti-pattern to declare cross-project dependencies is:

dependencies {
   // this is unsafe!
   implementation project(":other").tasks.someOtherJar
}

This publication model is unsafe and can lead to non-reproducible and hard to parallelize builds.

Don’t reference other project tasks directly!

You could define a configuration on the producer side which serves as an exchange of artifacts between the producer and the consumer.

consumer/build.gradle
dependencies {
    instrumentedClasspath(project(path: ":producer", configuration: 'instrumentedJars'))
}
consumer/build.gradle.kts
dependencies {
    instrumentedClasspath(project(mapOf(
        "path" to ":producer",
        "configuration" to "instrumentedJars")))
}

However, the consumer has to explicitly tell which configuration it depends on and this is not recommended. If you plan to publish the component which has this dependency, it will likely lead to broken metadata.

This section explains how to properly create cross-project boundaries by defining "exchanges" between projects by using variants.

Variant-aware sharing of artifacts

Gradle’s variant model allows consumers to specify requirements using attributes, while producers provide appropriate outgoing variants using attributes as well.

For example, a single dependency declaration like project(":myLib") can select either the arm64 or i386 version of myLib, based on the architecture.

To achieve this, attributes must be defined on both the consumer and producer configurations.

When configurations have attributes, they participate in variant-aware resolution. This means they become candidates for resolution whenever any dependency declaration, such as project(":myLib"), is used.

Attributes on producer configurations must be consistent with other variants provided by the same project. Introducing inconsistent or ambiguous attributes can lead to resolution failures.

In practice, the attributes you define will often depend on the ecosystem (e.g., Java, C++) because ecosystem-specific plugins typically apply different attribute conventions.

Consider an example of a Java Library project. Java libraries typically expose two variants to consumers: apiElements and runtimeElements. In this case, we are adding a third variant, instrumentedJars.

To correctly configure this new variant, we need to understand its purpose and set appropriate attributes. Here are the attributes on the runtimeElements configuration of the producer:

$ .gradle outgoingVariants --variant runtimeElements

Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 11
    - org.gradle.libraryelements     = jar
    - org.gradle.usage               = java-runtime

This tells us that the runtimeElements configuration includes 5 attributes:

  1. org.gradle.category indicates that this variant represents a library.

  2. org.gradle.dependency.bundling specifies that dependencies are external jars (not repackaged inside the jar).

  3. org.gradle.jvm.version denotes the minimum Java version supported, which is Java 11.

  4. org.gradle.libraryelements shows that this variant contains all elements typically found in a jar (classes and resources).

  5. org.gradle.usage defines the variant as a Java runtime, suitable for both compilation and runtime.

To ensure that the instrumentedJars variant is used in place of runtimeElements when executing tests, we must attach similar attributes to this new variant.

The key attribute for this configuration is org.gradle.libraryelements, as it describes what the variant contains. We can set up the instrumentedJars variant accordingly:

producer/build.gradle
configurations {
    instrumentedJars {
        canBeConsumed = true
        canBeResolved = false
        attributes {
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
            attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
            attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
            attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, JavaVersion.current().majorVersion.toInteger())
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
        }
    }
}
producer/build.gradle.kts
val instrumentedJars by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL))
        attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, JavaVersion.current().majorVersion.toInt())
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("instrumented-jar"))
    }
}

This ensures that the instrumentedJars variant is correctly identified as containing elements similar to a jar, allowing it to be selected appropriately.

Selecting the right attributes is the most challenging part of this process, as they define the semantics of the variant. Before introducing new attributes, always consider whether an existing attribute already conveys the required semantics. If no suitable attribute exists, you can create a new one. However, be cautious—adding a new attribute may introduce ambiguity during variant selection. In many cases, adding an attribute requires applying it consistently across all existing variants.

We’ve introduced a new variant for runtime that provides instrumented classes instead of the normal ones. As a result, consumers now face a choice between two runtime variants:

  1. runtimeElements - the default runtime variant provided by the java-library plugin.

  2. instrumentedJars - the custom variant we’ve added.

If we want the instrumented classes to be included on the test runtime classpath, we can now declare the dependency on the consumer as a regular project dependency:

consumer/build.gradle
dependencies {
    testImplementation 'junit:junit:4.13'
    testImplementation project(':producer')
}
consumer/build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13")
    testImplementation(project(":producer"))
}

If we stop here, Gradle will still resolve the runtimeElements variant instead of the instrumentedJars variant.

This happens because the testRuntimeClasspath configuration requests a variant with the libraryelements attribute set to jar, and our instrumented-jars value does not match.

To fix this, we need to update the requested attributes to specifically target instrumented jars:

consumer/build.gradle
configurations {
    testRuntimeClasspath {
        attributes {
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
        }
    }
}
consumer/build.gradle.kts
configurations {
    testRuntimeClasspath {
        attributes {
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, "instrumented-jar"))
        }
    }
}

We can look at another report on the consumer side to view exactly what attributes of each dependency will be requested:

$ .gradle resolvableConfigurations --configuration testRuntimeClasspath

Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 11
    - org.gradle.libraryelements     = instrumented-jar
    - org.gradle.usage               = java-runtime

The resolvableConfigurations report is the complement of the outgoingVariants report we ran previously.

By running both of these reports on the consumer and producer sides of a relationship, respectively, you can see exactly what attributes are involved in matching during dependency resolution and better predict the outcome when configurations are resolved.

At this point, we’re specifying that the test runtime classpath should resolve variants with instrumented classes.

However, there’s an issue: some dependencies, like JUnit, don’t provide instrumented classes. If we stop here, Gradle will fail, stating that no compatible variant of JUnit exists.

This happens because we haven’t told Gradle that it’s acceptable to fall back to the regular jar when an instrumented variant isn’t available. To resolve this, we need to define a compatibility rule:

consumer/build.gradle
abstract class InstrumentedJarsRule implements AttributeCompatibilityRule<LibraryElements> {

    @Override
    void execute(CompatibilityCheckDetails<LibraryElements> details) {
        if (details.consumerValue.name == 'instrumented-jar' && details.producerValue.name == 'jar') {
            details.compatible()
        }
    }
}
consumer/build.gradle.kts
abstract class InstrumentedJarsRule: AttributeCompatibilityRule<LibraryElements> {
    override fun execute(details: CompatibilityCheckDetails<LibraryElements>) = details.run {
        if (consumerValue?.name == "instrumented-jar" && producerValue?.name == "jar") {
            compatible()
        }
    }
}

We then declare this rule on the attributes schema:

consumer/build.gradle
dependencies {
    attributesSchema {
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
            compatibilityRules.add(InstrumentedJarsRule)
        }
    }
}
consumer/build.gradle.kts
dependencies {
    attributesSchema {
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
            compatibilityRules.add(InstrumentedJarsRule::class.java)
        }
    }
}

And that’s it! Now we have:

  • Added a variant which provides instrumented jars.

  • Specified that this variant is a substitute for the runtime.

  • Defined that the consumer needs this variant only for test runtime.

Gradle provides a powerful mechanism for selecting the right variants based on preferences and compatibility. For more details, check out the variant aware plugins section of the documentation.

By adding a value to an existing attribute or defining new attributes, we are extending the model. This means that all consumers must be aware of this extended model.

For local consumers, this is usually not a problem because all projects share the same schema. However, if you need to publish this new variant to an external repository, external consumers must also add the same rules to their builds for them to work.

This is generally not an issue for ecosystem plugins (e.g., the Kotlin plugin), where consumption is not possible without applying the plugin. However, it becomes problematic if you add custom values or attributes.

Therefore, avoid publishing custom variants if they are intended for internal use only.