skip to content

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?

level: seniorimportance: should knowfreq 30%

answer

  1. one flat 'classpath' configuration
  2. default conflict resolution = highest version
  3. force/constraints/exclude on buildscript.configurations.classpath
  4. failOnVersionConflict to surface clashes
  5. plugins {} isolation is the strategic fix

basics

~20 s

The 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 s

All `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 lines
kotlin
buildscript {
    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

for a junior

Recognize that the buildscript classpath is shared and version clashes can occur; not expected to resolve them.

for a middle

Explain that classpath is one configuration with conflict resolution and name force/exclude as remedies.

for a senior

Diagnose via build scan/custom task, apply constraints vs force vs exclude judiciously, and recommend plugins {} migration for isolation.

for a principal

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.

context