Why Kotlin DSL, Compose Compiler Plugin, and Version Catalog Break at Once

You migrate to Kotlin 2.0. You switch to libs.versions.toml. You convert build.gradle to build.gradle.kts. Now the build is on fire and you’re staring at three unrelated error messages simultaneously. Every Stack Overflow answer treats each tool in isolation. The problem is they don’t fail in isolation — they fail together, and the failures chain.

This page covers the real interaction effects: how a kotlin gradle plugin version mismatch hides a Compose runtime crash, why gradle version catalog not working in buildSrc is an architectural issue not a syntax problem, and why the same config that works in Groovy breaks in Kotlin DSL for reasons that have nothing to do with the code you wrote.

Official docs cover each tool separately. Nobody covers what happens when all three break during a single upgrade cycle. That’s what this page is for.


TL;DR

  • After Kotlin 2.0, compose compiler plugin setup requires an explicit org.jetbrains.kotlin.plugin.compose declaration — it’s no longer bundled automatically.
  • Gradle version catalog not working in buildSrc is not a TOML syntax problem — it’s classpath isolation by design.
  • DSL_SCOPE_VIOLATION Kotlin Gradle happens because plugins {} in Kotlin DSL evaluates before catalog accessors exist — use alias() instead.
  • Kotlin Gradle DSL vs Groovy: Groovy resolves lazily at runtime, Kotlin DSL resolves eagerly at configuration time — same syntax, different evaluation model, different failures.
  • Kotlin plugin already on classpath error means the plugin is applied in both buildSrc and root project — they share a classloader.
  • Compose compiler incompatible runtime version crashes at runtime, not compile time — verify both the plugin version and BOM-resolved compose-runtime explicitly.
  • Migrate in order: TOML catalog first → Kotlin DSL module by module → Kotlin 2.0 upgrade last. Doing all three simultaneously is where most failures originate.

How a Kotlin Gradle Plugin Version Mismatch Starts the Failure Chain

The kotlin gradle plugin version mismatch is the entry point for most broken migrations. Before Kotlin 2.0, the Compose Compiler was bundled with the Kotlin plugin and versioned together. After 2.0, it became a separate artifact with its own plugin ID. Nothing in your existing config signals this change — the old setup compiles, then crashes at runtime.

The mismatch shows up in two forms: either the compiler plugin version doesn’t match the Kotlin version, or the compiler-generated bytecode targets a runtime API version that differs from what’s on the classpath. Both look like an android gradle plugin kotlin version conflict in error output, but the root causes are different and require separate fixes.

// AGP and Kotlin versions that conflict silently
agp = "8.2.0" // expects compose compiler bundled
kotlin = "2.0.0" // compiler now a separate plugin
// Result: build passes, app crashes on ComposerKt at runtime

After Kotlin 2.0 you have three separate version coordinates: Kotlin itself, the Compose Compiler plugin (must equal Kotlin version exactly), and the Compose UI BOM (follows its own release schedule). Conflating any two of these is where the mismatch originates.

// libs.versions.toml — correct separation post Kotlin 2.0
[versions]
kotlin = "2.0.0"
compose-plugin = "2.0.0" # must match kotlin exactly
compose-bom = "2024.06.00" # independent — verify against compatibility matrix

Edge case: if you pin individual Compose artifact versions anywhere in the dependency graph, those pins override the BOM. Run ./gradlew :app:dependencies | grep compose-runtime to verify the resolved version rather than assuming the BOM controls everything.


Compose Compiler Plugin Setup After Kotlin 2.0: What Changed and What Breaks

The most common compose compiler plugin setup mistake after Kotlin 2.0 is assuming nothing changed. If your project worked on 1.9.x, the compiler was pulled in implicitly through org.jetbrains.compose. That implicit inclusion no longer happens. The compose compiler plugin kotlin 2.0 migration requires an explicit additional plugin declaration — missing it produces errors that look like version conflicts but aren’t.

Symptoms vary. Sometimes: This version of the Compose Compiler requires Kotlin 1.9.x even on 2.0. Sometimes: compose compiler runtime not found classpath. Sometimes the build succeeds entirely and the app crashes on first composable invocation with NoSuchMethodError: ComposerKt — no compile-time warning, no indication of the real cause.

// BEFORE Kotlin 2.0 — sufficient, compiler was bundled implicitly
plugins {
 id("org.jetbrains.compose") version "1.5.x"
}
// AFTER Kotlin 2.0 — both plugins required explicitly
plugins {
 id("org.jetbrains.compose") version "1.6.x"
 id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
 // compose plugin version must equal kotlin version, not compose UI version
}

The kotlin gradle plugin applied to non-compose module error appears when you add org.jetbrains.kotlin.plugin.compose to a module that has no actual Compose usage. The compiler extension scans for @Composable annotations — finding none, it either warns or errors depending on the Gradle version. Apply the Compose Compiler plugin only to modules with actual composable functions.

For multiplatform projects: the compiler plugin must be applied per-target module. Applying it only at root causes JVM targets to compile cleanly while Android targets fail at runtime — build succeeds, composables are missing.

Deep Dive
Kotlin Null Safety

Why Kotlin Null Safety Shapes Real-World Business Logic Many developers view nullability as a mere tool for avoiding crashes, but Kotlin Null Safety actually drives architectural decisions from the system's edge to the domain layer....


Gradle Version Catalog Not Working: libs.versions.toml in Android Studio vs Actual Build

The libs.versions.toml Android Studio setup creates a specific trap: the IDE resolves catalog references and shows them green, but Gradle sync fails. These are two entirely separate resolution systems. Android Studio uses cached generated accessors from the last successful sync. Gradle re-generates from scratch every time. If TOML or settings configuration changed in a way that breaks generation, the IDE appears fine while the actual build is broken.

Always validate with ./gradlew help from terminal before declaring catalog setup correct. IDE sync status is not confirmation.

// root settings.gradle.kts — catalog declaration belongs here, not in build.gradle.kts
dependencyResolutionManagement {
 versionCatalogs {
 create("libs") {
 from(files("gradle/libs.versions.toml"))
 }
 }
}

If libs resolves in app/build.gradle.kts but fails in a library module, check for nested settings.gradle.kts files in subdirectories. A nested settings file creates an isolated Gradle build that doesn’t inherit the root catalog declaration — the isolated build compiles independently, the parent project never gets its catalog references resolved.


Gradle Version Catalog Unresolved Reference libs in buildSrc

Gradle version catalog unresolved reference libs inside buildSrc is not a configuration mistake you can fix by adjusting TOML syntax. buildSrc is a separate Gradle build with its own classpath. The libs accessor is generated for the root build context. buildSrc doesn’t inherit it — by design.

The companion error — buildSrc version catalog extension does not exist — appears when you attempt to use VersionCatalogsExtension inside buildSrc without explicitly re-registering the catalog there. The extension isn’t registered in that isolated build context regardless of what the root project declares.

// buildSrc/build.gradle.kts — this will NOT compile
dependencies {
 implementation(libs.kotlin.stdlib) // Unresolved reference: libs
 // libs accessor doesn't exist in the buildSrc build context
}

The libs versions toml buildSrc not working problem has two resolution paths:

Option 1 — Kotlin object for versions (simplest): keep version strings in a plain Versions.kt file inside buildSrc/src/main/kotlin/. No catalog dependency, zero architectural overhead.

// buildSrc/src/main/kotlin/Versions.kt
object Versions {
 const val kotlin = "2.0.0"
 const val agp = "8.4.0"
}
// Use as: implementation("org.jetbrains.kotlin:kotlin-stdlib:${Versions.kotlin}")

Option 2 — Composite build with catalog re-declaration: migrate from buildSrc to a composite build named build-logic, then re-declare the catalog in its own settings.gradle.kts.

// build-logic/settings.gradle.kts
dependencyResolutionManagement {
 versionCatalogs {
 create("libs") {
 from(files("../gradle/libs.versions.toml"))
 }
 }
}
// libs is now accessible inside build-logic convention plugins

The path inside from(files(...)) is relative to the included build’s settings file, not the root project. A wrong relative path creates a silent failure — Gradle generates an empty catalog and every reference becomes unresolved with no useful error. Verify with ./gradlew :build-logic:dependencies.


DSL_SCOPE_VIOLATION Kotlin Gradle: Why plugins {} Is a Restricted Scope

DSL_SCOPE_VIOLATION Kotlin Gradle is the most searched error in this migration and the least clearly explained in official documentation. The error text — “The plugins {} block must not be used here” or “Only dependencies and version overrides can be specified” — describes the symptom without explaining the cause.

The plugins {} block runs during Gradle’s settings phase, before the project model is built and before catalog accessors are generated. Calling libs.versions.kotlin.get() inside that block fails because libs doesn’t exist at that evaluation point. In Groovy, dynamic dispatch deferred the call to runtime so the violation was invisible. Kotlin DSL’s static resolution surfaces it immediately at configuration time.

// Groovy — works due to lazy dynamic dispatch (technically wrong, silently forgiven)
plugins {
 id 'org.jetbrains.kotlin.android' version libs.versions.kotlin.get()
}

// Kotlin DSL — DSL_SCOPE_VIOLATION, fails at configuration time
plugins {
 id("org.jetbrains.kotlin.android") version libs.versions.kotlin.get()
 // libs doesn't exist yet in this evaluation phase
}

The fix: declare plugins under [plugins] in TOML and reference them with alias() inside plugins {}. That accessor is the only one Gradle permits in the restricted plugins block scope.

// libs.versions.toml
[plugins]
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

// build.gradle.kts — correct usage
plugins {
 alias(libs.plugins.kotlinAndroid) // no .get(), no string interpolation
}

The libs.versions.toml plugin alias IDE error appears when a plugin entry is placed under [libraries] instead of [plugins] — a common copy-paste error when converting from Groovy configs. Library entries generate libs.something accessors; plugin entries generate libs.plugins.something accessors. Wrong section placement produces a “unknown version” error rather than pointing at the structural issue.

Known limitation: alias() combined with .apply(false) has issues in Gradle versions before 8.1. For declaring a plugin without applying it on older toolchains, fall back to string ID in that specific case.


Kotlin Gradle DSL vs Groovy: Same Config, Different Failures

The kotlin gradle dsl vs groovy divergence has one root explanation: Groovy uses dynamic method dispatch and lazy property resolution; Kotlin DSL uses static typing and eager configuration-time evaluation. The same line of code is not semantically equivalent across the two DSLs.

The kotlin gradle dsl unresolved reference plugin error surfaces most often when configuring extensions that a plugin provides, before that plugin is confirmed applied. Groovy deferred the method call to runtime and it happened to work. Kotlin DSL requires the extension to be registered before the block that configures it executes.

// Groovy — works regardless of plugin application order
subprojects {
 android { compileSdk 34 }
}

// Kotlin DSL — fails if android plugin not yet applied at this point
subprojects {
 extensions.configure<com.android.build.gradle.BaseExtension> {
 compileSdkVersion(34)
 }
}

The gradle kotlin dsl allprojects version catalog variant: using libs.xxx inside allprojects or subprojects blocks in Kotlin DSL. The catalog accessor is available in the root project’s build script context. When those blocks delegate execution to subproject contexts, the accessor may not resolve correctly in the delegated scope. The fix is moving plugin applications and catalog references into individual module build files or into convention plugins inside build-logic.

Technical Reference
Kotlin Under the Hood:...

Kotlin Pitfalls: Beyond the Syntactic Sugar   Moving to Kotlin isn't just about swapping semicolons for conciseness. While the marketing says "100% interoperable" and "null-safe," the reality in a Kotlin codebase complexity environment is different....

The diagnostic question for any Kotlin DSL failure that worked in Groovy: “at what phase is this evaluated?” — not “is my syntax wrong?” Phase ordering is the issue in nearly every case. Syntax is rarely the problem.


Kotlin Plugin Already on Classpath Error

The kotlin plugin already on classpath error — full message: The Kotlin Gradle plugin was already applied to the project or Plugin with id 'kotlin-android' is already on the classpath — appears when the plugin is applied from two build contexts that share a classloader.

The version catalog plugin already on classpath unknown version variant is distinct: it surfaces when the catalog plugin is declared explicitly in buildSrc/build.gradle.kts and also triggered automatically by dependencyResolutionManagement.versionCatalogs in root settings. The catalog plugin applies itself when you use that block — declaring it again causes a conflict with no clear version to report.

// buildSrc/build.gradle.kts
plugins {
 `kotlin-dsl` // applies kotlin-jvm internally, no explicit version
}

// root build.gradle.kts — versioned re-application conflicts
plugins {
 kotlin("jvm") version "2.0.0" // already on classpath from buildSrc
}

Fix: inside buildSrc and build-logic, never declare the Kotlin plugin with an explicit version. Use it without a version qualifier — Gradle picks up what’s already on the classpath from the root project. One versioned declaration per plugin, in one location.

// buildSrc/build.gradle.kts — correct approach
plugins {
 `kotlin-dsl`
 // No explicit kotlin version — avoids classloader conflict
}

The error also appears when you have both buildSrc and a composite build included simultaneously, both declaring versioned Kotlin. Migrate fully to one approach before adding versioned plugin declarations anywhere outside the root.


Compose Compiler Incompatible Runtime Version: Silent Build, Runtime Crash

Compose compiler incompatible runtime version is the hardest failure in this migration because it’s completely silent at build time. The build succeeds, the app launches, then crashes: IllegalStateException: Composition requires Compose runtime 1.x but found 1.y or NoSuchMethodError: ComposerKt. The stack trace points at a composable that looks correct in code.

The compose compiler runtime not found classpath variant appears when the runtime artifact is missing entirely — usually because the BOM isn’t applied at the right level or the runtime dependency was excluded somewhere in the dependency graph without the author realizing it.

// Conflicting setup that compiles and then crashes at runtime
kotlin = "2.0.0"
compose-plugin = "2.0.0" # correct — matches kotlin
compose-bom = "2024.02.00" # pulls compose-runtime 1.6.x
# Compiler 2.0 targets runtime APIs not present in 1.6.x
# Silent build, crash on first composable call

The official Compose-to-Kotlin version compatibility mapping lists verified combinations. Cross-reference before upgrading either coordinate independently — don’t assume the latest BOM is automatically compatible with the latest Kotlin release.

// Verify actual resolved compose-runtime version — don't trust TOML alone
// ./gradlew :app:dependencies | grep compose-runtime
// Two different versions in output = a transitive dep is overriding the BOM

If you pin individual Compose artifact versions anywhere in the project, those pins take precedence over the BOM. A single implementation("androidx.compose.runtime:compose-runtime:1.6.0") buried in a library module overrides what the BOM specifies at app level.


TOML Syntax Error Line Column: Why the Error Points at the Wrong Place

Gradle TOML syntax error line column messages are structurally misleading. Gradle’s TOML parser reports the error at the point where the violation becomes unambiguous to the parser — not where the actual mistake was made. A duplicate [versions] section created by copy-pasting entries triggers an error on the second occurrence, not on the structural duplication that caused the problem.

[versions]
kotlin = "2.0.0"

[libraries]
stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }

[versions] # error reported here — real problem is this duplicate header
agp = "8.4.0" # all entries below belong in the first [versions] block

TOML requires each table header exactly once. All entries under that header must appear in one contiguous block. Canonical order for Gradle catalogs: [versions][libraries][bundles][plugins], each appearing once. Android Studio’s TOML syntax highlighter doesn’t always flag duplicate section headers — it shows no red underlines while Gradle’s parser fails hard on sync.


Migration Order That Prevents Compounding Failures

Running the Kotlin 2.0 upgrade, Version Catalog migration, and Kotlin DSL conversion simultaneously in one branch is the primary cause of compound failures. Each change has its own failure surface. Layered together, the failures interact and produce symptoms that don’t point back to their actual source.

The sequence that isolates each failure layer:

  1. Migrate to libs.versions.toml first, keeping all Groovy build files unchanged. Verify catalog resolves across every module via terminal ./gradlew help, not IDE sync. Resolve any buildSrc catalog isolation issues at this stage, before touching anything else.
  2. Convert to Kotlin DSL one module at a time, starting from leaf modules with no subproject dependencies. Root build file and app module last. Verify each module compiles before converting the next. DSL_SCOPE_VIOLATION and unresolved reference errors surface in isolation here.
  3. Upgrade Kotlin to 2.0 after DSL conversion is fully stable. This surfaces Compose Compiler issues without DSL noise mixed in. Add org.jetbrains.kotlin.plugin.compose to all Compose modules at this step.
  4. Verify Compose runtime compatibility explicitly — check the compatibility matrix, run the dependency tree, confirm resolved compose-runtime version matches compiler expectations.
  5. Migrate buildSrc to composite build if catalog access in build logic is required — handle this as a standalone effort, not bundled into the Kotlin upgrade.

Steps 2 and 3 done simultaneously generate the hardest-to-diagnose failures: Kotlin DSL type errors and Kotlin 2.0 breaking changes produce nearly identical symptoms. Separating them turns a multiday debugging session into two predictable bounded efforts.

Worth Reading
Stop struggling with Kotlin...

Solving Kotlin Type Inference Problems for Junior and Middle Developers Kotlin is praised for its concise syntax and safety, but it can trip up developers in subtle ways. One major challenge is Kotlin type inference...


FAQ

Why does kotlin gradle plugin version mismatch happen after Kotlin 2.0 if I didn’t change any version?

Because the Compose Compiler plugin moved to a separate artifact in Kotlin 2.0. Your Kotlin version string didn’t change in your config, but the implicit compiler inclusion that worked before is now gone. The mismatch is between the missing new plugin and the runtime that expects it — nothing in the old config signals this gap.

What exactly causes DSL_SCOPE_VIOLATION in Kotlin Gradle and how do I fix it for good?

The plugins {} block runs before catalog accessors are generated. Calling libs.anything inside it fails because libs doesn’t exist yet at that phase. Declare the plugin under [plugins] in TOML and reference it with alias(libs.plugins.yourPlugin) — the only accessor that works in the plugins block’s restricted scope.

Gradle version catalog not working in buildSrc — can I fix it without migrating to composite builds?

Yes: add a separate settings.gradle.kts inside buildSrc with its own dependencyResolutionManagement.versionCatalogs block pointing at the root TOML file via relative path. It works but means the catalog is declared in two places. Composite builds solve this more cleanly and are the direction Gradle is actively pushing toward.

libs.versions.toml in Android Studio shows no errors but Gradle sync fails — what’s happening?

Android Studio caches generated catalog accessors from the last successful sync and uses that cache for IDE resolution. Gradle re-generates from scratch. If the catalog configuration broke after the last good sync, the IDE still shows the cached state as valid. Confirm catalog correctness with a terminal Gradle run, not IDE status.

Kotlin Gradle DSL vs Groovy — is there a reliable rule for what will break after conversion?

One rule covers most cases: if the Groovy code relies on dynamic property resolution or deferred method calls, it will break in Kotlin DSL. This includes anything inside plugins {} that isn’t a string literal or alias(), anything inside allprojects/subprojects that configures extensions from not-yet-applied plugins, and any property access that happens before the providing plugin is registered.

Compose compiler incompatible runtime version — how do I find which combinations are actually safe?

The official Compose-to-Kotlin version compatibility table on the Jetpack Compose releases page lists verified pairings. Rule: org.jetbrains.kotlin.plugin.compose version must exactly equal your Kotlin version. The Compose BOM version is independent — check the table for which BOM releases are validated against which Kotlin versions before upgrading either independently.

Kotlin plugin already on classpath — does it matter if it’s still a warning and not an error?

Before AGP 8.x and Gradle 8.x it was a warning that compiled through. Those versions promoted it to a hard error. If it’s still a warning on your current toolchain, the next toolchain upgrade will harden it. Fix it before that happens: remove versioned Kotlin plugin declarations from buildSrc and build-logic, version declarations belong in root or TOML only.

Can I keep some modules in Groovy DSL and others in Kotlin DSL during migration?

Yes. Gradle supports mixed DSL within the same project. .gradle and .gradle.kts files coexist without issues — the constraint is that a single build file must use one DSL throughout. Migrating incrementally module by module is fully supported and the safest approach.


Conclusion

The Kotlin 2.0 migration window is uniquely disruptive because three independent tool changes — Compose Compiler Plugin separation, Version Catalog adoption, Kotlin DSL conversion — happen to land simultaneously for most projects moving to modern Android tooling. Official documentation covers each tool correctly in isolation. It doesn’t model what happens when all three interact during a single upgrade cycle.

The compound failure pattern is predictable once you’ve seen it: a kotlin gradle plugin version mismatch surfaces first, you try to fix it via TOML adjustments, hit DSL_SCOPE_VIOLATION referencing the catalog inside plugins {}, move declarations around to resolve that, surface a kotlin plugin already on classpath error from buildSrc, fix that, upgrade Kotlin, then get compose compiler incompatible runtime version at runtime because the BOM wasn’t updated to match the new compiler.

Each individual fix is straightforward. The compound failure is what wastes days. Sequence the migration — catalog first, DSL second, Kotlin 2.0 third — so each failure layer surfaces in isolation and points at its own cause. Trying to fix all three simultaneously means every error could be caused by any of three things. That’s the situation the official guides put you in. This page is the one that gets you out.

 

Written by:

Source Category: Kotlin: Hidden Pitfalls