skip to content

How do you make two third-party modules that don't declare any shared capability become mutually exclusive in Gradle?

level: seniorimportance: should knowfreq 30%

answer

  1. dependencies { components { withModule } }
  2. allVariants { withCapabilities { addCapability } }
  3. add same capability coordinate to both
  4. ComponentMetadataRule + @CacheableRule
  5. no republish needed

basics

~20 s

Add a component metadata rule that attaches the same capability to both modules: components.withModule(...) { allVariants { withCapabilities { addCapability(group, name, version) } } }. Once both share a capability, Gradle treats them as conflicting.

solid answer

~40 s

Most third-party modules only declare their implicit GAV capability, so Gradle has no way to know two of them are the same feature. You teach it with **component metadata rules** in the `dependencies { components { ... } }` block. For each module, use `withModule("group:name")` and, inside `allVariants` (or `withVariant("runtime")` for precision), call `withCapabilities { addCapability(group, name, version) }` with the *same* synthetic capability coordinate for both modules: ```kotlin dependencies { components { withModule("org.slf4j:log4j-over-slf4j") { allVariants { withCapabilities { addCapability("log4j", "log4j", "1.0") } } } // log4j:log4j already declares log4j:log4j implicitly } } ``` Now both claim `log4j:log4j` and Gradle raises a capability conflict, which you then resolve. Metadata rules run during resolution, are cacheable, and don't require the modules to be republished.

code

kotlin · 21 lines
kotlin
dependencies {
    components {
        // make google-collections claim guava's capability => conflict
        withModule("com.google.collections:google-collections") {
            allVariants {
                withCapabilities {
                    addCapability("com.google.guava", "guava", "1.0")
                }
            }
        }
    }
}

configurations.all {
    resolutionStrategy.capabilitiesResolution.withCapability("com.google.guava:guava") {
        candidates.firstOrNull {
            (it.id as? ModuleComponentIdentifier)?.module == "guava"
        }?.let { select(it) }
        because("prefer modern guava over legacy google-collections")
    }
}

go deeper

for a junior

Recognize that you can add capabilities to modules you didn't publish, via component rules.

for a middle

Write a withModule rule adding a capability and explain it triggers a conflict.

for a senior

Use a @CacheableRule ComponentMetadataRule class, scope to variants, and pair with a resolution rule.

for a principal

Ship such rules in a shared plugin so capability policy is uniform and cached across the org's builds.

## Why you need to declare it Gradle can only detect a capability conflict if **two variants declare the same capability**. A third-party module you didn't publish typically declares only its implicit GAV capability. So `org.slf4j:log4j-over-slf4j` and `log4j:log4j` each declare their own GAV and never clash, even though they are functionally exclusive. You bridge this with **component metadata rules**. ## Component metadata rules These rules let you *amend* the metadata of components as Gradle resolves them — without republishing. They live under `dependencies { components { ... } }`: - **`withModule("group:name") { ... }`** — target one module. - **`all { ... }`** — apply to every module (filter inside by `id`). - Inside, you operate on variants: **`allVariants { ... }`**, or **`withVariant("runtime") { ... }`** to scope to a usage. - **`withCapabilities { addCapability(group, name, version) }`** adds a capability to that variant. ## The two-module pattern To make module A and module B mutually exclusive, make them declare the **same** capability. Often the cleanest is to add the *other module's* implicit capability to one of them: ```kotlin dependencies { components { // log4j:log4j implicitly provides capability log4j:log4j // make the bridge provide the SAME capability: withModule("org.slf4j:log4j-over-slf4j") { allVariants { withCapabilities { addCapability("log4j", "log4j", "1.0") } } } } } ``` Now both declare `log4j:log4j`, so requesting both triggers a conflict. ## Then resolve it Declaring the conflict is only half the job — by default it fails the build. Pair it with `resolutionStrategy.capabilitiesResolution.withCapability("log4j:log4j") { select(...) }` to choose the winner. ## Where rules can live - Inline in the build script (shown above). - As a reusable **class implementing `ComponentMetadataRule`** with `@CacheableRule`, registered via `components { withModule("...", MyRule::class.java) }`. This is the production-grade form — cacheable and shareable across projects/plugins. ```kotlin @CacheableRule abstract class LoggingCapabilityRule : ComponentMetadataRule { override fun execute(ctx: ComponentMetadataContext) { ctx.details.allVariants { withCapabilities { addCapability("log4j", "log4j", "1.0") } } } } ``` ## Caching note Mark rule classes `@CacheableRule` so Gradle can cache their effect; rules with inputs that change must be designed accordingly. Inline closures are not cached the same way, so reusable rules should be classes.

  • Where do component metadata rules live in the DSL?
    Under dependencies { components { ... } }, using withModule("group:name") or all { }, then allVariants/withVariant + withCapabilities.addCapability.
  • Why mark a ComponentMetadataRule class with @CacheableRule?
    So Gradle can cache the rule's effect across builds instead of re-evaluating it every resolution, which is required for performant, reusable rules.
  • After declaring the shared capability, is the work done?
    No — declaration only creates the conflict (a failure). You still need a capabilitiesResolution rule to select the winner.

saying these in an interview costs you the question

  • Forgetting that declaring a shared capability makes the build FAIL until you also write a resolution rule.
  • Using exclude() instead — that hides a transitive dep but doesn't model mutual exclusivity or let consumers choose.
  • Putting heavy logic in an uncacheable inline closure instead of a @CacheableRule class for reuse.

context