How do you make two third-party modules that don't declare any shared capability become mutually exclusive in Gradle?
answer
- dependencies { components { withModule } }
- allVariants { withCapabilities { addCapability } }
- add same capability coordinate to both
- ComponentMetadataRule + @CacheableRule
- no republish needed
basics
~20 sAdd 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 sMost 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 linesdependencies {
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
Recognize that you can add capabilities to modules you didn't publish, via component rules.
Write a withModule rule adding a capability and explain it triggers a conflict.
Use a @CacheableRule ComponentMetadataRule class, scope to variants, and pair with a resolution rule.
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.