Kotlin Gradle Plugin Failures: KAPT vs KSP Traps, Multiplatform SourceSet Issues and Build Cache Problems
A practical troubleshooting guide for Android, backend, and Kotlin Multiplatform projects — covering annotation processing, serialization, coroutines, linting, and incremental build issues.
- Single-target legacy: Most Kotlin plugins were designed for single-target JVM builds. Applying them to multiplatform graphs causes silent compilation sync errors.
- Platform asymmetry: A successful Android/JVM compilation guarantees nothing — iOS and JS targets use distinct IR transformations and fail on identical code.
- Codegen disconnect: KAPT and KSP process annotations at different pipeline stages (Java stubs vs. Kotlin symbols), breaking builds during migration or simultaneous use.
- Stale cache invalidation: Gradle frequently fails to detect plugin updates or sourceSet structural shifts, serving broken binaries from the local build cache.
- Native runtime restrictions: Serialization and Coroutines plugins generate platform-specific code that hits hard limits on native targets — zero reflection fallback, stricter threading.
- Linter blindness: KtLint and Detekt parse code without sourceSet context, evaluating multiplatform structures against single-target rules.
1. Multiplatform Core: SourceSet and Hierarchy Issues
The kotlin("multiplatform") plugin orchestrates your entire cross-platform codebase, defining how sourceSets connect and compile across targets. While the official documentation treats sourceSet configuration as a straightforward declarative tree, underneath it lies a fragile directed graph. Managing this graph manually — or letting third-party plugins manipulate it via implicit dependsOn configurations — frequently causes the compiler’s symbol visibility matrix to desynchronize, quietly isolating platform targets from their shared code logic.
The following three cases represent the most common failure patterns in the sourceSet layer. They often appear after a plugin update or when merging sourceSets from separate modules, and are frequently misdiagnosed as Xcode or linker errors when they are actually Gradle task-graph problems.
iOS target cannot see common module code after sourceSet merge
You merged sourceSets, the JVM target compiles clean, Android runs fine — and then the iOS framework build crashes with “unresolved reference” on symbols that clearly exist in commonMain. No helpful stack trace. No documentation warning. Just a broken Xcode build and a compiler that seems to be lying to you.
Kotlin Multiplatform’s sourceSet model is a directed graph, not a flat inheritance tree. Visibility propagates along declared dependency edges — and when those edges are reconfigured through dependsOn changes or plugin-managed hierarchy modifications, the compiler’s view of which symbols are accessible from which target changes silently.
This specific failure sits at the intersection of KMP’s dependency resolution model, Gradle’s incremental build cache, and the way Xcode consumes generated frameworks. The edge cases around iOS target visibility after sourceSet configuration changes are largely undocumented, community-discovered, and frequently misdiagnosed as Xcode issues when they are actually Gradle problems.
// build.gradle.kts
kotlin {
sourceSets {
// Plugin X silently adds dependsOn here during configuration phase,
// breaking the iosMain → commonMain visibility edge
val iosMain by getting {
dependencies { implementation(project(":shared-utils")) }
}
}
}
// After plugin update: iosArm64Main no longer sees commonMain symbols.
// JVM compiles fine. iOS framework build: "Unresolved reference: MySharedClass"
kotlin {
sourceSets {
val iosMain by getting {
dependsOn(commonMain.get()) // explicit, not implicit
dependencies { implementation(project(":shared-utils")) }
}
}
}
Common module dependency not visible in iOS target framework
A dependency declared in commonMain is supposed to propagate to all targets. But when the dependency is a multiplatform library with its own sourceSet hierarchy, resolution depends on the artifact metadata format. If the library publishes iosArm64 metadata but not iosSimulatorArm64, the iOS Simulator target resolves nothing and the symbols disappear from the framework. The JVM and Android targets compile fine because they consume JVM artifacts, not platform-specific metadata.
# Check what artifacts a library actually publishes
./gradlew dependencies --configuration iosSimulatorArm64MainImplementation
# If the library is missing iosSimulatorArm64 artifacts, you'll see:
# > Could not resolve com.example:some-lib:1.0.0
# No matching variant of com.example:some-lib:1.0.0 was found.
expect/actual mismatch after Gradle plugin update
The expect/actual matching algorithm changed in Kotlin 1.9.x and again in the 2.x series. After a plugin update, previously valid expect/actual pairs fail to match because the compiler’s signature comparison now includes annotations or default parameter handling that wasn’t checked before. The error message says “actual declaration has no corresponding expect declaration” — technically accurate but completely unhelpful for locating what changed.
// commonMain
expect class PlatformDate(@Suppress("UNUSED") millis: Long)
// iosMain — compiled fine before Kotlin 2.x, fails after:
// "actual declaration has no corresponding expect declaration"
actual class PlatformDate(millis: Long) { ... }
// Fix: mirror the annotation on the actual declaration
actual class PlatformDate(@Suppress("UNUSED") millis: Long) { ... }
| SourceSet | Target | Issue | Common cause |
|---|---|---|---|
commonMain |
iosArm64 |
Dependency resolves for JVM but not iOS framework | Library missing iosArm64/iosSimulatorArm64 metadata |
commonMain |
iosSimulatorArm64 |
Symbol invisible after sourceSet merge | dependsOn graph broken after plugin update |
iosMain |
iosX64 |
expect/actual mismatch post Kotlin 2.x upgrade |
Compiler signature comparison changed for annotations |
commonTest |
All iOS | Test source not compiled into framework | Test sourceSets excluded from framework publication |
2. Annotation Processing: KAPT vs KSP Traps
KAPT and KSP share the goal of processing annotations at build time but operate at completely different levels of the compilation pipeline. KAPT converts Kotlin to Java stubs and runs Java annotation processors. KSP operates directly on Kotlin’s symbol model without stub generation. This architectural difference produces divergent behavior on specific Kotlin language features — features that appear in both tools’ documentation as “supported” but behave differently in practice.
Additionally, KAPT’s design assumes a single-target JVM compilation model. When you add it to a multiplatform project, you are running a JVM-centric tool against a compiler pipeline that now targets iOS, JS, and Wasm simultaneously. The following cases document the most common failure patterns across both tools.
Annotation processor cannot find generated class after clean build
After ./gradlew clean, the processor-generated classes disappear from the compile classpath even though the annotation processor ran successfully. The build log shows processor execution, the generated files exist on disk, but the compiler reports “unresolved reference” on the very classes that were just generated.
This happens because KAPT runs as a separate Gradle task that outputs to a directory outside the standard source compilation classpath by default in multiplatform setups. When the project has multiple targets, the classpath wiring between KAPT output and the main compilation task depends on which target’s compilation task runs first. After a clean, task ordering changes, and the KAPT-generated sources directory is not registered on the classpath before the compiler task starts. It is a task dependency graph problem disguised as an annotation processor failure.
> Task :kaptGenerateStubsKotlin SUCCESS
> Task :kaptKotlin SUCCESS
> Task :compileKotlin FAILED
e: /src/main/kotlin/MyService.kt:12:5
error: unresolved reference: MyRepoImpl
// Generated file EXISTS on disk:
// build/generated/source/kapt/main/com/example/MyRepoImpl.java
// But it is NOT on the compile classpath for this target.
// build.gradle.kts
kotlin {
sourceSets {
val main by getting {
kotlin.srcDir("build/generated/source/kapt/main")
}
}
}
// Or, declare the task dependency explicitly:
tasks.named("compileKotlin") {
dependsOn("kaptKotlin")
}
KAPT processes annotations by converting Kotlin bytecode to Java stubs. Inline functions do not survive this conversion intact — they get mangled because inlining is a compiler-level operation that KAPT’s stub generation phase does not reproduce correctly. The error messages are typically ClassNotFoundException on the stub class or a generic “error while processing” message with a stack trace pointing into KAPT internals, not your code.
Annotated inline functions that use reified type parameters are especially problematic. KAPT generates a stub that erases the type parameter, producing a different method signature than what the annotation processor expects to find.
@MyAnnotation
inline fun reified T> resolve(): T {
return container.get(T::class)
}
// KAPT stub erases T → generates: Object resolve()
// Processor expects: T resolve() with reified metadata
// Result: ClassNotFoundException or silent stub mismatch
WorkaroundAvoid annotating inline functions with processors that rely on method signature inspection. Extract the annotated logic into a non-inline wrapper function and keep the inline function as a thin call site.
Generated file missing in Gradle incremental compilation
Incremental KAPT tracks which source files changed and re-runs only the relevant processors. The tracking mechanism uses file modification timestamps and a state file stored in the build directory. If any build step modifies the KAPT state file — including certain Gradle daemon restarts and plugin version changes — the incremental state becomes inconsistent. The processor runs but writes output to a stale location, or skips output entirely because the state file claims the target class is already up to date.
# Delete KAPT incremental state, not just the build output
rm -rf build/tmp/kapt3
rm -rf build/generated/source/kapt
./gradlew kaptKotlin
KSP processor does not work with sealed classes across files
KSP processes sealed classes through its KSClassDeclaration API, which exposes sealed subclasses via getSealedSubclasses(). This call only returns subclasses declared in the same file or in files that KSP has already processed in the current build round. Sealed hierarchies split across multiple files — valid Kotlin since 1.5 — cause KSP to see an incomplete hierarchy during processing. Processors that generate exhaustive when-expression wrappers or registry classes based on sealed subclasses produce incomplete output without any error or warning from KSP.
// Result.kt
sealed class Result
// Success.kt — separate file, KSP may miss this in round 1
class Success(val data: String) : Result()
// Error.kt
class Error(val message: String) : Result()
// KSP-generated registry may only contain Success,
// missing Error entirely — no compilation error, silent bug.
WorkaroundDeclare sealed subclasses in the same file as the sealed parent, or register a KSP defer() call so the processor re-runs in a second round after all files are visible.
Annotation processor skips value classes (inline classes)
Value classes compile to a box type and a specialized unboxed representation. KAPT sees only the box type when generating stubs. A processor that annotates a value class generates code based on the box representation, missing the unboxed call sites that appear in actual usage. KSP has better value class support but still does not expose the unboxed representation through its API — processors see the value class as a wrapper, not as the underlying type, producing generated code that compiles but does not match the runtime method signatures.
Kotlin Performance Optimization Starts Where Most Developers Stop Looking Kotlin doesnt have a performance problem — it has a perception problem. Kotlin performance optimization is not about the language itself, but about the hidden cost...
Suspend function annotation processing fails with KAPT (Hilt / Room)
When a coroutines-annotated function is processed by KAPT, the annotation processing happens on the Java stub representation. The stub for a function that uses suspend does not include the coroutine continuation parameter that the Kotlin compiler adds during suspension point transformation. Any annotation processor that generates code based on the method signature — particularly Room or Hilt — generates code against the stub signature, not the actual compiled signature, producing a method-not-found error at runtime that does not appear until the generated code executes a coroutine call path.
// Kotlin source
@Query("SELECT * FROM users")
suspend fun getUsers(): List<User>
// KAPT stub (Java representation — continuation stripped)
// List<User> getUsers()
// Actual compiled Kotlin bytecode (what Room needs to call)
// Object getUsers(Continuation<? super List<User>> continuation)
// Room generates code targeting the stub → MethodNotFoundException at runtime
Generated code location differs between KAPT and KSP
KAPT writes generated sources to build/generated/source/kapt/<variant>/. KSP writes to build/generated/ksp/<target>/<sourceSet>/. Projects migrating from KAPT to KSP need to update any hardcoded references to generated source directories in their build scripts, IDE configurations, and CI pipelines. More critically, the generated package structure can differ between processors because KAPT processors targeting Java often generate classes in the root package, while KSP processors following Kotlin conventions generate into the annotated class’s package.
// Before (KAPT)
android {
sourceSets["main"].java.srcDirs(
"build/generated/source/kapt/debug"
)
}
// After (KSP)
android {
sourceSets["main"].java.srcDirs(
"build/generated/ksp/debug/kotlin"
)
}
| Problem | Tool | Manifestation | Notes |
|---|---|---|---|
| Sealed class hierarchy across files | KSP | Incomplete registry/mapper generated | KSP rounds don’t guarantee cross-file visibility |
| Inline/value class annotation | KAPT | Box-type stub only, unboxed path invisible | Unboxed call sites missing from generated code |
| Suspend function annotation processing | KAPT | Stub loses suspension metadata | Java stubs cannot represent Kotlin suspension semantics |
| Generated source path migration | Both | Import errors after KAPT→KSP switch | Package structure conventions differ between tools |
| Incremental processor re-run | KAPT | Re-runs on unrelated file changes | KAPT incremental tracking is file-level, not symbol-level |
3. Gradle Plugin Version and Build Issues
Gradle’s plugin ecosystem for Kotlin has multiple independently versioned components: the Kotlin Gradle Plugin, the Android Gradle Plugin, KAPT, KSP, and the Compose Compiler plugin. They do not version-lock to each other. Compatibility matrices exist but are not enforced at build time — mismatches produce failures that look like unrelated compiler errors.
Gradle build fails after Kotlin plugin update despite JVM version match
The Kotlin Gradle Plugin embeds a specific version of the Kotlin compiler. Updating the plugin updates the embedded compiler. If the compiler version changes how it handles certain language constructs — particularly around type inference improvements or IR backend changes — code that compiled before will fail after the update even though the JVM target version has not changed. The failure typically shows up as a type mismatch or overload resolution ambiguity.
# Print the embedded Kotlin compiler version
./gradlew kotlinVersion
# Or check in the Gradle build scan — look for:
# "org.jetbrains.kotlin:kotlin-compiler-embeddable:X.Y.Z"
Incremental compilation breaks after changing Kotlin compiler plugin
The IR backend maintains its own incremental compilation state separate from the classic backend’s state files. Switching between backends — or updating a plugin that changes which backend is used — leaves stale state files that the new backend either misreads or ignores. The build then either recompiles everything (best case) or compiles with a corrupted incremental state that produces incorrect output (worse, harder to detect).
rm -rf build/kotlin
./gradlew --rerun-tasks compileKotlin
Classpath mismatch between Kotlin DSL and Groovy build scripts
Kotlin DSL buildscripts are compiled against the Gradle API classpath at configuration time. When a plugin adds its own classes to the buildscript classpath, the Kotlin DSL compiler resolves types differently than the Groovy evaluator. A plugin that works perfectly in a Groovy build file can produce “unresolved reference” errors in the equivalent Kotlin DSL configuration because the plugin’s classes are not on the Kotlin DSL compilation classpath at the right phase.
// Groovy — works
apply plugin: 'com.example.myplugin'
myPlugin { option = "value" }
// Kotlin DSL — fails: "Unresolved reference: myPlugin"
apply(plugin = "com.example.myplugin")
configure<MyPluginExtension> { option = "value" }
// Fix: add plugin to buildscript classpath explicitly
buildscript {
dependencies {
classpath("com.example:myplugin:1.0.0")
}
}
4. Coroutines, Flow, and Suspend Traps
Kotlin’s coroutines library interacts with the compiler through a series of transformations — suspension point insertion, continuation generation, state machine creation. This section covers both inline function interaction issues (which affect all targets) and multiplatform-specific failures (which are typically iOS-only and surface late in the development cycle). Even after the new memory model became the default in Kotlin 1.7.20, certain patterns around Flow, StateFlow, and structured concurrency still produce iOS-specific runtime failures that do not appear during JVM or Android testing.
Inline and crossinline interaction with coroutines
suspend function does not compile with inline crossinline usage
A crossinline lambda inside an inline function cannot be a suspend lambda because crossinline prohibits non-local returns, and the coroutine state machine transformation requires the ability to restructure control flow in ways that conflict with crossinline‘s constraints. The compiler error “suspension functions can only be called within coroutine body” is accurate but does not explain that the root cause is the crossinline modifier, not the calling context.
inline fun withContext(crossinline block: suspend () -> Unit) {
runBlocking { block() }
// ↑ Compiler error: "Suspension functions can only be called within coroutine body"
// Real cause: crossinline + suspend are incompatible modifiers
}
// Fix: remove crossinline, allow non-local returns
inline fun withContext(block: suspend () -> Unit) {
runBlocking { block() }
}
Coroutine Flow builder crashes with cryptic exception on Android target
The flow { } builder uses an internal SafeFlow implementation that wraps emissions in exception handling. On Android, the R8/D8 bytecode transformation pipeline can remove or inline parts of this wrapping in ways that break the exception contract. The runtime exception — typically a FlowCollector cancellation leak — appears in production under specific concurrency conditions that do not reproduce in debug builds because R8 operates only in release mode.
Diagnosis tipIf a crash appears only in release builds and the stack trace points to SafeFlow or FlowCollector, add -keep class kotlinx.coroutines.flow.** { *; } to your ProGuard/R8 rules and verify whether the crash disappears. If it does, the issue is R8 optimizing away coroutine internals.
Shared module coroutines behave differently between JVM and JS
Coroutines in commonMain compile to different underlying implementations depending on the target. JVM uses thread-based coroutine dispatchers; JS uses a single-threaded event loop model. Code that assumes thread-safe coroutine behavior in commonMain — particularly anything using Mutex or Channel with structured concurrency — will behave differently on JS where blocking operations simply do not exist. The JS target compiles successfully but produces runtime behavior that diverges from the JVM implementation in subtle, hard-to-test ways.
iOS-specific coroutine failures
Suspend lambda in shared module fails compilation on iOS only
Suspend lambdas in commonMain compile to different representations on JVM (continuation-passing style) and on Kotlin/Native (native coroutine primitives). A suspend lambda that captures a mutable variable from the enclosing scope compiles on JVM because the JVM’s garbage collector handles the captured reference lifecycle. On Kotlin/Native, captured mutable references require explicit memory management annotations in certain coroutine contexts — their absence produces a compile-time error that only manifests on the iOS target.
// commonMain — compiles on JVM, fails on iosArm64
var counter = 0
val lambda: suspend () -> Unit = {
counter++ // mutable capture — illegal in Kotlin/Native coroutine context
}
// Fix: make the captured reference immutable or use AtomicInt
val counter = AtomicInt(0)
val lambda: suspend () -> Unit = { counter.incrementAndGet() }
Flow builder throws “Flow invariant is violated” on iOS
The flow { } builder enforces a single-collector constraint through an internal flag on the FlowCollector. On Kotlin/Native, the threading model means this flag check runs on a different thread than the flag setter in certain Dispatcher configurations. The result is an IllegalStateException with the message “Flow invariant is violated” — accurate for the symptom but pointing nowhere toward the actual cause: a Dispatcher mismatch between the flow producer and consumer that is legal on JVM but illegal on native’s threading model.
// Problematic: Dispatchers.Default mixes threads on native
val myFlow = flow {
emit(fetchData())
}.flowOn(Dispatchers.Default)
// Fix: collect on the same dispatcher that produces
scope.launch(Dispatchers.Main) {
myFlow
.flowOn(Dispatchers.Main)
.collect { ... }
}
5. Serialization Pitfalls
The kotlinx.serialization plugin operates as a compiler plugin that generates serializer implementations at compile time. It hooks into the IR transformation pipeline and faces the same multi-target complexity as other Kotlin compiler plugins. Each target needs a correctly generated serializer, and the generation logic has several documented and undocumented gaps around visibility modifiers, sealed class hierarchies, and platform-specific type mappings.
Advanced Kotlin Data Mapping: Patterns, Performance, and Best Practices That Actually Hold Up in Production Every Kotlin service has a boundary — the line between the raw, unpredictable data coming from the outside world and...
@Serializable not recognized on internal sealed class
The serialization plugin processes class visibility before generating serializer code. An internal sealed class in commonMain is internal to the module, but the generated serializer companion object needs to be accessible from the platform-specific modules that consume the serialized data. The plugin generates the companion with the same visibility as the sealed class, making it inaccessible from the consuming platform module. The build succeeds; serialization fails at runtime with a “Serializer not found” exception that points at the class rather than the visibility problem.
// Fails at runtime — serializer generated as internal
@Serializable
internal sealed class ApiResponse
// Fix: make the sealed class public,
// or use @PublishedApi if internal access is required
@Serializable
sealed class ApiResponse
// Or suppress visibility propagation explicitly:
@Serializable
@OptIn(ExperimentalSerializationApi::class)
internal sealed class ApiResponse {
companion object : KSerializer<ApiResponse> { ... }
}
Serialization plugin fails to detect enum in common module
Enum classes in commonMain require the serialization plugin to generate a serializer that maps enum constants to their string or ordinal representations. When the enum is declared in a file that is also processed by KAPT or KSP, the compiler plugin execution order determines whether the serialization plugin sees the original enum declaration or KAPT’s transformed version. If KAPT runs first and modifies the class metadata, the serialization plugin may not recognize the class as an enum and silently skips serializer generation.
DiagnosisRun the build with --info and grep for SerializationPlugin. If you see no output for a known @Serializable enum, the plugin is skipping it due to a processing order conflict. Separating the enum into its own file, away from KAPT-annotated classes, resolves this in most cases.
Generated JSON adapter does not match target platform (iOS-only failure)
The serialization plugin generates platform-specific serializer implementations. The JSON format serializer for JVM uses reflection-based fallbacks for certain type scenarios; the native (iOS) serializer cannot use reflection and requires fully code-generated paths. When a type uses a feature that the code generation path does not support — certain generic type combinations, delegated properties, or companion object customizations — the JVM serializer works via reflection fallback while the iOS serializer fails at compile time with a generation error. This asymmetry makes serialization bugs iOS-only, discovered late in the development cycle.
@Serializable
data class Wrapper<T : Any>(val value: T)
// JVM: works via reflection fallback
// iOS: compile error — "Serializer for type T cannot be generated"
// Fix: provide a concrete serializer at the call site
@Serializable
data class StringWrapper(val value: String)
// Or use @Contextual for types that need runtime lookup
@Serializable
data class Wrapper<T : Any>(@Contextual val value: T)
| Class type | Issue | Target platform | Manifestation |
|---|---|---|---|
internal sealed class |
Serializer generated with wrong visibility | All | Runtime “Serializer not found” exception |
enum in commonMain |
Plugin execution order conflict with KAPT | JVM / Android | Silent skip of serializer generation |
| data class with generics | Reflection fallback unavailable on native | iOS (iosArm64) |
Compile-time generation error, iOS-only |
expect/actual class |
Serializer generated for expect, not actual | iOS / JS | ClassCastException or missing serializer at runtime |
6. Incremental Compilation and Build Errors
Gradle’s incremental compilation for Kotlin tracks changed files, their dependencies, and the output classes they produce. In multiplatform projects, this tracking multiplies across targets — each target maintains its own incremental state. Cross-target state corruption is common and produces failures that appear target-specific but are actually Gradle state management issues.
Incremental compilation fails when merging sourceSets
When you add a new dependsOn relationship between sourceSets — or remove one — the incremental compilation state for both sourceSets becomes invalid. Gradle does not detect this structural change as a reason to invalidate the compilation cache. The compiler then operates with an incremental state that was built from a different sourceSet graph than is currently configured, producing stale symbol resolution that passes or fails inconsistently depending on which files were last modified.
# Remove Kotlin incremental compilation state for all targets
find build -name "*.kotlin_module" -delete
find build -name "last-build.bin" -delete
./gradlew clean build
Build works on JVM target but fails on JS or Android
The Kotlin/JVM, Kotlin/JS, and Kotlin/Android backends compile to different intermediate representations and have backend-specific compiler checks. Code that uses JVM-specific APIs directly — even through expect/actual — may compile for JVM while the JS or Android backend lacks an equivalent actual declaration. Incremental compilation makes this worse: if the JVM target compiles first and caches its output, a subsequent JS build may skip recompilation of unchanged files and fail only on recently modified files, making the failure seem unrelated to the actual source of the problem.
Gradle cache not invalidated after plugin update
Gradle’s build cache uses task input hashing to determine cache hits. Plugin JARs are included in the task input hash, so updating a plugin should theoretically invalidate the cache. In practice, the Kotlin Gradle Plugin’s task inputs do not always include all compiler plugin JARs in the hash. After an update, cached task outputs from the previous plugin version get reused, producing binaries compiled with the old compiler against new API expectations.
# Invalidate local cache
./gradlew clean --rerun-tasks
# Invalidate local + remote — disable local cache temporarily
./gradlew build
-Dorg.gradle.caching=false
--rerun-tasks
# Or add a unique build parameter to change the task input hash
./gradlew build -PcacheBuster=$(date +%s)
7. Multiplatform Android and iOS Nuances
Android and iOS targets consume the shared module through fundamentally different mechanisms. Android links against the compiled Kotlin classes directly through Gradle dependency resolution. iOS consumes a compiled framework artifact produced by the Kotlin/Native compiler. This means the same commonMain code goes through two entirely different compilation and linking pipelines, and failures are often pipeline-specific rather than code-specific.
Android target sees common module but iOS does not
Android target compilation resolves commonMain symbols through Gradle’s dependency graph at compile time. iOS compilation resolves them during Kotlin/Native framework generation, which happens as a separate Gradle task with its own classpath. If the framework generation task does not have the correct dependencies on the tasks that compile commonMain, it runs with a stale or empty classpath. The Android build succeeds because it resolves at compile time; the iOS framework build fails because it resolves at framework generation time with a different task graph.
# Print task dependency graph for iOS framework generation
./gradlew linkDebugFrameworkIosArm64 --dry-run
# Look for: compileKotlinIosArm64 appearing BEFORE linkDebugFrameworkIosArm64
# If it's missing, add an explicit dependency:
tasks.named("linkDebugFrameworkIosArm64") {
dependsOn("compileKotlinIosArm64")
}
Shared module resources not pulled into iOS framework
Resources declared in commonMain/resources are not automatically included in the Kotlin/Native framework. The framework compilation process compiles Kotlin sources and generates headers — resource bundling is a separate, manually configured step. Developers who expect resource access patterns from Android (where the Android Gradle Plugin handles resource merging) find that the same approach produces a framework with missing assets that fails at runtime, not at compile time, making the missing resources hard to detect during development.
expect/actual mismatch breaking iOS compilation after plugin update
After a Kotlin Gradle Plugin update, the compiler’s rules for what constitutes a valid actual declaration for a given expect may change. The iOS target uses Kotlin/Native, which has stricter rules around nullability, platform type handling, and generic variance than the JVM target. An actual declaration that was previously accepted by the iOS compiler may be rejected after the update due to stricter checking, while the same actual on JVM continues to compile. The error appears only in the iOS build, creating a situation where the shared codebase compiles on one platform but not another despite no source changes.
8. Linting, Formatting, Detekt and KtLint Traps
Both KtLint and Detekt were designed for single-target Kotlin projects. Their rule engines parse Kotlin source files without awareness of multiplatform sourceSet context. Rules that are syntactically valid get flagged because the linter does not know which target the file belongs to, and rules that should apply to specific targets get evaluated against the wrong context.
KtLint does not format suspend lambda properly
KtLint’s formatter handles lambda formatting through a rule that inspects the lambda’s position in the AST. Suspend lambdas have a different AST node structure than regular lambdas — the suspend keyword introduces an additional node level that KtLint’s lambda-formatting rule does not account for. The formatter either skips the lambda entirely or applies single-argument lambda formatting rules incorrectly, producing output that re-triggers the formatter on the next run — an infinite formatting loop if you have formatting as a pre-commit hook.
WorkaroundAdd a KtLint .editorconfig override to disable the lambda-is-last-parameter rule for files containing suspend lambdas, or suppress with // ktlint-disable on the specific line. Track the upstream issue — this is a known KtLint bug with a fix pending.
Detekt reports issues on inline functions even with correct style
Detekt’s complexity metrics — particularly CyclomaticComplexity and CognitiveComplexity — calculate function complexity based on control flow graph analysis. Inline functions get analyzed independently, without accounting for the fact that their complexity distributes to call sites. A utility inline function with a few branches gets flagged as too complex even though each call site only experiences a linear subset of that complexity. Suppressing the finding requires per-function annotations, which pollutes the source with noise that the CI pipeline then treats as suppression debt.
@Suppress("CyclomaticComplexMethod")
inline fun evaluateConditions(block: () -> Boolean): Boolean {
// Complexity is distributed to call sites at compile time
...
}
KtLint breaks multiline string in Kotlin DSL
Multiline strings in Kotlin DSL build files use trimIndent() or trimMargin() to handle indentation. KtLint’s string template rules apply indentation normalization that conflicts with trimIndent‘s expected input format. After KtLint formatting, the trimIndent call receives differently indented content and produces incorrect output at runtime — a build configuration that silently uses wrong values rather than failing with an error.
val query = // ktlint-disable
"""
SELECT *
FROM users
WHERE active = true
""".trimIndent()
// ktlint-enable
Practical Kotlin Unit Testing Writing Kotlin unit tests often feels like a double-edged sword. On one hand, the language provides expressive syntax that makes assertions look like natural language. On the other hand, developers frequently...
9. FAQ
iOS and Multiplatform Build Failures
./gradlew clean even though everything compiled before?Clean removes the incremental compilation state and forces Gradle to rebuild the full task graph from scratch. Task ordering that worked with warm caches may not hold after clean — particularly around KAPT output registration and Kotlin/Native framework generation dependencies, which rely on implicit task ordering that only holds when certain outputs already exist on disk.
The fix pattern is to explicitly declare task dependencies in the build script rather than relying on implicit ordering:
tasks.named("linkDebugFrameworkIosArm64") {
dependsOn("kaptKotlin", "compileKotlinIosArm64")
}
If the failure is intermittent — passes on some machines but not others — the root cause is almost always a missing explicit dependency rather than a code problem. CI environments are more likely to expose this because they always start from clean.
Android and iOS consume the shared module through entirely different pipelines. Android resolves commonMain symbols through Gradle’s dependency graph at compile time. iOS resolves them during Kotlin/Native framework generation — a separate Gradle task with its own classpath and task graph.
The three most common causes of this split: a library missing iosArm64 or iosSimulatorArm64 metadata (JVM artifacts resolve fine, native ones do not); a dependsOn edge broken silently after a plugin update; or an actual declaration rejected by the stricter Kotlin/Native compiler after a version bump. Run ./gradlew linkDebugFrameworkIosArm64 --info and look for classpath or resolution errors before the link step.
Start by verifying the dependsOn graph is intact — run ./gradlew dependencies --configuration iosArm64MainImplementation and confirm the expected symbols appear. If the dependency is present but symbols are still missing, the issue is usually one of three things: the library does not publish native metadata for that target; the framework generation task ran before commonMain finished compiling; or the incremental state is stale after a sourceSet structure change.
Delete build/kotlin and build/tmp/kapt3, then run a clean build with --rerun-tasks to rule out cache corruption before investigating further.
KAPT, KSP and Annotation Processing
KSP operates on Kotlin’s symbol model directly; KAPT operates on Java stub representations of Kotlin code. The two models expose different information about generics, nullability, value classes, and suspend functions. A processor that inspects method signatures, for example, will see the Kotlin continuation parameter through KSP but not through KAPT — because KAPT stubs strip suspension metadata when converting to Java.
Processor logic that works correctly in KAPT often needs rewriting for KSP because the API surface differs significantly, not just the performance characteristics. The most common breakage points during migration are: sealed class subclass discovery (getSealedSubclasses() in KSP is round-dependent), value class handling (KAPT sees only the box type), and generated package structure (KSP follows Kotlin conventions, KAPT follows Java).
Technically yes, but the interaction is fragile and the failure mode is subtle. Both processors write to different output directories and run as separate Gradle tasks, so they do not directly conflict at the file level. The problem appears when both process the same annotations — you get duplicate generated classes in different packages that cause compilation errors, or one processor’s output silently shadows the other’s.
The safe migration pattern is to move processors one at a time. Keep KAPT only for processors that do not have a KSP equivalent yet, and configure KSP for everything else. Never point both at the same annotation on the same class. You can verify which processor is handling which annotation by checking the generated source directories: KAPT writes to build/generated/source/kapt/, KSP to build/generated/ksp/.
KAPT incremental tracking operates at the file level, not the symbol level. Any change to a file that contains an annotated class — even a comment edit or a whitespace change — triggers a full re-run of all processors that declared interest in that file. This is fundamentally different from KSP, which tracks at the symbol level and only re-runs processors whose input symbols changed.
If KAPT is re-running constantly despite no meaningful changes, check whether a code generator or IDE plugin is touching source files on every build (a common cause is auto-generated copyright headers or timestamp injection). You can verify by running ./gradlew kaptKotlin --info and looking for which files Gradle considers changed in the inputs.
Gradle Cache and Incremental Compilation
There is no single command that reliably clears everything, because Gradle has three separate cache layers that need to be invalidated independently.
The local build cache lives in ~/.gradle/caches/ — clear it with ./gradlew clean plus deleting build/kotlin and build/tmp/kapt3 manually. Running ./gradlew --rerun-tasks forces task re-execution but does not clear cached outputs, so stale binaries can still be restored from cache after the task runs.
To also invalidate remote cache hits, either temporarily disable the local cache with buildCache { local { isEnabled = false } }, or change a task input to produce a new cache key:
# Force a unique cache key without changing source
./gradlew build -PcacheBuster=$(date +%s)
After a plugin version bump specifically, also delete ~/.gradle/caches/modules-*/files-*/org.jetbrains.kotlin/ to ensure the old compiler JARs are not reused.
Incremental compilation maintains a state file that maps source files to their output classes and dependencies. When the sourceSet structure changes — a new dependsOn added, a module split, a plugin update that reorganizes compilation tasks — this state file becomes inconsistent with the actual graph. Gradle does not detect structural changes as cache invalidation triggers, so the compiler operates against a stale symbol map.
The tell-tale sign is a build that passes incrementally but fails after clean, or passes on one developer’s machine but fails on CI (which always builds clean). If you see this pattern, the fix is always to delete the incremental state manually: rm -rf build/kotlin build/tmp/kapt3 and run a full rebuild.
Serialization Plugin Issues
The failure layer determines where to look and what to fix:
Compile-time failures from the serialization plugin produce errors in the :compileKotlinIosArm64 or equivalent task — typically “Serializer for type X cannot be generated” or a missing companion object error. These are plugin configuration or type compatibility issues.
Runtime failures produce SerializationException at the call site with a message like “Serializer not found for class X”. These usually mean the serializer was generated with wrong visibility (internal sealed class case) or was generated for the expect declaration instead of the actual.
Silent failures — where serialization succeeds but produces incorrect output — are the hardest to catch. They happen when the JVM target falls back to reflection-based serialization for a type that the native target handles through a code-generated path, and the two produce different JSON. The only reliable way to catch these is per-target serialization round-trip tests that compare output schemas across JVM and iOS builds.
The serialization plugin generates platform-specific serializer implementations. The JVM serializer has a reflection-based fallback path for types it cannot fully code-generate — generic type combinations, certain delegated properties, companion object customizations. This fallback silently handles cases that the native serializer cannot, because Kotlin/Native has no reflection at runtime.
When a type hits the reflection fallback on JVM, serialization works. On iOS, the same type triggers a compile-time generation error because there is no fallback. The asymmetry means the bug was always present on JVM — it was just masked. The fix is always on the type definition side: remove the unsupported feature (usually unbounded generics or delegated properties in serializable classes), or provide a custom serializer explicitly.
Linting and Static Analysis
Detekt’s complexity metrics — CyclomaticComplexity, CognitiveComplexity — are computed from the AST, which is produced by the Kotlin compiler front-end. The AST structure changes between Kotlin language versions, so a complexity score for the same function can differ between Kotlin 1.9.x and 2.x.
CI environments often pin a different Kotlin version than local development through the Gradle wrapper, a different JDK version, or a Detekt version specified in the CI config that differs from the one in build.gradle.kts. The result is that the same inline function scores differently and crosses the complexity threshold on one environment but not the other.
The reliable fix is to pin Detekt version and Kotlin language version explicitly in the Detekt config file and verify both match between local and CI. Add languageVersion to your detekt.yml:
complexity:
CyclomaticComplexMethod:
threshold: 15
And in your Gradle config, pin the compiler version Detekt uses:
detekt {
buildUponDefaultConfig = true
config.setFrom("$rootDir/detekt.yml")
// ensure Detekt uses the same Kotlin version as your build
source.setFrom("src/main/kotlin")
}
Repeated reformatting on the same file is almost always caused by one of three things: a rule that KtLint applies that another tool (the IDE formatter, for example) immediately reverts; a suspend lambda formatting issue where KtLint’s AST rule produces output that triggers itself on the next pass; or a trimIndent() block in a Kotlin DSL file where KtLint normalizes indentation in a way that is semantically incorrect.
To diagnose which rule is looping, run KtLint twice in succession and diff the output:
./gradlew ktlintFormat
./gradlew ktlintFormat
git diff # should be empty if stable
If the diff is not empty, the changed lines point directly at the unstable rule. Disable that specific rule in .editorconfig rather than suppressing KtLint globally:
[*.kt]
ktlint_standard_lambda-is-last-parameter = disabled
10. Conclusion
After working through Kotlin Gradle plugin failures across Android, JVM, and Kotlin Multiplatform projects, the pattern becomes clear: most of these issues are not bugs in the traditional sense. They are integration failures — places where tools designed independently meet at the build pipeline level and produce behavior that neither tool’s documentation acknowledges.
KAPT was built for single-target JVM projects before Kotlin Multiplatform existed. KSP was designed with KMP in mind but shipped with unresolved gaps around sealed class visibility across files and value class processing that remain open on YouTrack across multiple Kotlin releases. The kotlinx.serialization compiler plugin handles JVM well but silently falls back to reflection for type combinations it cannot code-generate — a fallback that simply does not exist on Kotlin/Native, which is why serialization bugs are disproportionately iOS-only and discovered late. Gradle’s build cache invalidation was designed for single-target builds and scales poorly to the multi-target task graphs that KMP produces.
None of this is documented in the official Kotlin Multiplatform setup guide, the KSP readme, or the kotlinx.serialization reference. The knowledge lives in YouTrack issue threads marked “investigating”, in Stack Overflow answers with thirty upvotes and no accepted answer, and in internal runbooks at companies that have been running KMP in production long enough to hit every edge case. The gap between what the documentation covers and what production builds actually encounter is where most kotlin multiplatform build errors, kotlin gradle plugin compatibility issues, and kapt ksp migration problems originate.
The practical takeaway is diagnostic rather than prescriptive: when a Kotlin build breaks in a way that the error message does not explain, the failure is almost never in your code. It is in the boundary between two tools — the annotation processor and the compiler, the framework generation task and the compilation task, the serialization plugin and the IR backend. Knowing which boundary to look at, and why that boundary exists, is what makes the difference between a ten-minute fix and a two-day investigation.
Early warning signals for engineering teams
These three patterns reliably surface deeper build system problems before they become release blockers. Each one maps to a specific class of kotlin gradle build failure that is worth investigating immediately rather than retrying:
- iOS-only failures after a Kotlin plugin version bump — almost always an expect/actual signature mismatch or sourceSet visibility break caused by stricter compiler checks introduced in the new version. The JVM target compiles because it is more permissive; the Kotlin/Native compiler is not.
- Clean build failures that do not reproduce incrementally — KAPT output path registration or Kotlin/Native framework task ordering problem. This pattern is never flaky. It is a missing explicit task dependency that warm-cache builds happen to satisfy by accident.
- Serialization exceptions on one platform but not another — either a plugin execution ordering conflict with KAPT that causes silent serializer skip, or a reflection fallback on JVM masking a missing code-generated serializer path on native. Both require per-target testing to catch before production.
Written by: