A team's buildscript {} classpath has two plugins pulling conflicting versions of a shared transitive library. What is going on and how do you address it?
answer
- one flat 'classpath' configuration
- default conflict resolution = highest version
- force/constraints/exclude on buildscript.configurations.classpath
- failOnVersionConflict to surface clashes
- plugins {} isolation is the strategic fix
basics
~20 sThe buildscript classpath is a single flat dependency graph, so two plugins' transitive deps must be reconciled to one version — a clash can break a plugin. Fix it with classpath resolution rules, version constraints, or by migrating to plugins {} for isolation.
solid answer
~40 sAll `classpath(...)` dependencies in a `buildscript {}` form **one flat resolvable configuration** named `classpath`. Like any Gradle configuration, Gradle picks a single version per module via conflict resolution (highest by default). If two plugins were built against incompatible versions of a shared transitive library, the winning version can break the other plugin at build time. Because it is a normal configuration, you can intervene the usual ways: apply a `resolutionStrategy` to `buildscript.configurations["classpath"]` (force a version, fail on conflict), add `constraints`, or exclude a transitive. The cleaner long-term fix is to move plugin application to the `plugins {}` block, which gives each plugin a more isolated classpath and avoids forcing one flat resolution. You can inspect the graph with a custom task that resolves `buildscript.configurations["classpath"]` or via build scans.
code
kotlin · 13 linesbuildscript {
repositories { mavenCentral() }
dependencies {
classpath("com.example:plugin-a:1.0")
classpath("com.example:plugin-b:2.0")
}
configurations.classpath {
resolutionStrategy {
failOnVersionConflict() // surface clashes loudly
force("com.shared:shared-lib:1.4") // pin the agreed version
}
}
}go deeper
Recognize that the buildscript classpath is shared and version clashes can occur; not expected to resolve them.
Explain that classpath is one configuration with conflict resolution and name force/exclude as remedies.
Diagnose via build scan/custom task, apply constraints vs force vs exclude judiciously, and recommend plugins {} migration for isolation.
Set policy to minimize buildscript classpaths, enforce failOnVersionConflict, and drive migration to plugins {} + catalogs to eliminate flat-classpath clashes org-wide.
## The root cause: one flat classpath Every `classpath(...)` line in `buildscript {}` adds to a single Gradle **configuration** literally named `classpath`. Configurations are dependency graphs subject to **conflict resolution**: when two dependencies (often transitive) request different versions of the same module, Gradle by default selects the **highest** version and uses it everywhere on that classpath. So if PluginA was compiled against `shared-lib:1.0` and PluginB drags in `shared-lib:2.0`, both plugins run against `2.0` — and if `2.0` removed an API PluginA needs, PluginA fails (often `NoSuchMethodError`/`ClassNotFoundException` at configuration time). ## Diagnosing - A **build scan** shows the buildscript dependency tree. - A custom task can resolve and print the classpath: ```kotlin tasks.register("showBuildscriptClasspath") { val cp = buildscript.configurations["classpath"] doLast { cp.resolvedConfiguration.resolvedArtifacts.forEach { println(it.moduleVersion) } } } ``` ## Fixes, least to most invasive 1. **Force / constrain a version.** Treat the classpath like any configuration: ```kotlin buildscript { configurations.classpath { resolutionStrategy { force("com.shared:shared-lib:1.0") // or: failOnVersionConflict() to surface clashes early } } } ``` 2. **Add constraints** instead of brute force (preferred — they participate in resolution rather than overriding it). 3. **Exclude** a bad transitive: `classpath("...") { exclude(group = "...", module = "...") }`. 4. **Bump one plugin** to a version compiled against the newer shared lib so the flat resolution is compatible. 5. **Migrate to `plugins {}`.** Because the plugins DSL gives plugins more isolated ClassLoaders (rather than one flat classpath), it sidesteps the forced single-version problem entirely. This is the strategic fix. ## Why this matters at scale Flat-classpath clashes are a classic large-build pain point — adding one plugin silently changes a transitive version used by another. Standardizing on `plugins {}` + version catalogs and keeping plugin sets minimal reduces the blast radius. When a buildscript classpath is unavoidable, pin versions explicitly and turn on `failOnVersionConflict()` so surprises fail loudly instead of producing mysterious runtime errors.
- How does Gradle decide which version wins on the buildscript classpath by default?It treats classpath as a normal configuration and applies conflict resolution: by default the highest requested version of each module is selected and used everywhere on that classpath.
- Why does migrating to plugins {} help with these conflicts?The plugins DSL gives plugins more isolated ClassLoaders rather than one shared flat classpath, so two plugins no longer have to agree on a single version of a shared transitive library.
- What's the difference between force() and a dependency constraint here?force() overrides resolution unconditionally; a constraint participates in resolution (it can be overridden by a stronger requirement and is more transparent). Constraints are generally preferred for maintainability.
saying these in an interview costs you the question
- Assuming each buildscript classpath entry is isolated — they share one flat configuration.
- Reaching for force() before understanding the clash, hiding incompatibilities instead of resolving them.