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.
This specific failure pattern sits at the intersection of Kotlin Multiplatform’s dependency resolution model, Gradle’s incremental build cache, and the way Xcode consumes generated frameworks. It confuses junior and mid-level developers because the official KMP documentation covers the happy path — symmetric sourceSets, clean builds, no plugin version drift. The edge cases around iOS target visibility after sourceSet configuration changes are largely undocumented, community-discovered, and frequently misdiagnosed as Xcode issues when they’re actually Gradle problems.
The sections below dissect the most persistent, least-documented failure modes across annotation processing, multiplatform compilation, Gradle plugin behavior, coroutines, serialization, and linting — the full stack of where Kotlin plugins quietly break production builds.
TL;DR
- iOS target visibility failures after sourceSet merges are a Gradle resolution problem, not an Xcode issue.
- KAPT and KSP behave fundamentally differently around incremental compilation and sealed class hierarchies.
- Gradle’s build cache does not automatically invalidate when plugin versions change — you have to force it.
- Coroutine inline/crossinline combinations inside shared modules hit KAPT-specific bytecode generation bugs.
- The
@Serializableplugin fails silently oninternalsealed classes incommonMain. - KtLint and Detekt both have undocumented misbehaviors in multiplatform project configurations.
Annotation Processing Issues: KAPT/KSP Traps
KAPT’s design assumes a single-target JVM compilation model. When you add it to a multiplatform project, you’re running a JVM-centric tool against a compiler pipeline that now targets iOS, JS, and Wasm simultaneously. The mismatch surfaces in specific, reproducible ways.
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 isn’t registered on the classpath before the compiler task starts. It’s a task dependency graph problem disguised as an annotation processor failure.
kapt fails with cryptic error on inline functions
KAPT processes annotations by converting Kotlin bytecode to Java stubs. Inline functions don’t survive this conversion intact — they get mangled because inlining is a compiler-level operation that KAPT’s stub generation phase doesn’t 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.
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.
@Repository // processed by KAPT
interface MyRepo {
fun getItems(): List<Item>
}// After clean build:
// KAPT task: SUCCESS
// :compileKotlin: FAILED
// error: unresolved reference: MyRepoImpl
// Generated file exists at build/generated/source/kapt/
// but is NOT on the compile classpath for this target
Multiplatform sourceSet and Target Visibility
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.
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.
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,” which is technically accurate but completely unhelpful for locating what changed.
| SourceSet | Target | Issue Description | Common Pitfall |
|---|---|---|---|
| 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 |
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 don’t version-lock to each other. Compatibility matrices exist but aren’t 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 hasn’t changed. The failure typically shows up as a type mismatch or overload resolution ambiguity that wasn’t present before.
Why Most Kotlin Developers Misuse Variables — And Pay for It at Runtime Standard Kotlin tutorials teach you val x = 5 and move on. What they skip is everything that actually matters: how Kotlin...
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 case, harder to detect).
classpath mismatch Kotlin DSL vs Groovy
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 aren’t on the Kotlin DSL compilation classpath at the right phase.
Coroutines and Inline Functions Pitfalls
Kotlin’s coroutines library interacts with the compiler through a series of compiler transformations — suspension point insertion, continuation generation, state machine creation. Inline functions that participate in this transformation pipeline create a layered compilation problem where the inline expansion happens before the coroutine transformation, and the result doesn’t always produce valid bytecode.
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 here is accurate but the message — “suspension functions can only be called within coroutine body” — doesn’t explain that the root cause is the crossinline modifier, not the calling context.
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 don’t reproduce in debug builds because R8 operates only in release mode.
shared module coroutines conflict 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 don’t exist. The JS target compiles successfully but produces runtime behavior that diverges from the JVM implementation in subtle, hard-to-test ways.
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 doesn’t 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 doesn’t account for. The formatter either skips the lambda entirely or applies single-argument lambda formatting rules incorrectly, producing output that doesn’t match the configured style and that re-triggers the formatter on the next run — an infinite formatting loop if you have formatting as a pre-commit hook.
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.
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.
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.
@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.
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’s 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 skips serializer generation silently.
generated JSON adapter does not match target platform
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 doesn’t 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.
RIP _state: Why Kotlin Explicit Backing Fields Actually Matter More Than the Release Notes Admit Explicit Backing Fields (EBF) in Kotlin 2.4.0 are moving out of experimental, which means the old underscore-based _state convention can...
| 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 |
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 doesn’t 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 currently configured, producing stale symbol resolution that passes or fails inconsistently depending on which files were last modified.
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 files that were recently modified, 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, Gradle’s remote build cache and the local build cache use different invalidation strategies, and the Kotlin Gradle Plugin’s task inputs don’t 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.
KAPT vs KSP Edge Cases
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 that appear in both tools’ documentation as “supported” but behave differently in practice.
KSP processor does not work with sealed classes
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 — which is 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.
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. Code that imports generated classes by hardcoded package reference breaks silently during migration.
annotation processor skips inline classes
Value classes (formerly inline classes) in Kotlin 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 doesn’t expose the unboxed representation through its API — processors see the value class as a wrapper, not as the underlying type, which produces generated code that compiles but doesn’t match the runtime method signatures.
| Problem | Manifestation | Notes | Related LSI |
|---|---|---|---|
| Sealed class hierarchy across files | KSP generates incomplete registry/mapper | KSP rounds don’t guarantee cross-file visibility | KSP incremental build problems |
| Inline/value class annotation | KAPT generates box-type stub only | Unboxed path invisible to stub generator | annotation processor Kotlin quirks |
| Generated source path migration | Import errors after KAPT→KSP switch | Package structure conventions differ | KSP vs KAPT comparison |
| Incremental processor re-run | KAPT re-runs on unrelated file changes | KAPT incremental tracking is file-level, not symbol-level | KSP incremental build problems |
| Suspend function annotation processing | KAPT stub loses suspension metadata | Java stubs cannot represent Kotlin suspension semantics | annotation processor setup issues |
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 doesn’t 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.
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.
Kotlin Bytecode Bloat: What Aggressive Inlining Does to JVM Performance There's a particular kind of performance problem that doesn't show up in unit tests, doesn't trigger alerts, and looks perfectly reasonable in code review. You're...
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.
Coroutines, Flow, and Suspend Traps
The coroutines library’s interaction with Kotlin/Native’s memory model has historically been a source of multiplatform-specific failures. 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 don’t appear during JVM or Android testing.
suspend lambda in shared module fails compilation
Suspend lambdas in commonMain compile to different representations on JVM (using continuation-passing style) and on Kotlin/Native (using 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.
flow builder throws cryptic exception 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 a IllegalStateException with the message “Flow invariant is violated” — a message that accurately describes the symptom but points 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.
inline crossinline coroutine usage fails with KAPT
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 inline with crossinline lambda parameters doesn’t 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 — dependency injection processors, particularly Room or Hilt — generates code against the stub signature, not the actual compiled signature, producing a method-not-found error at runtime that doesn’t appear until the generated code executes a coroutine call path.
FAQ
-
Why does the iOS build fail after I run
./gradlew cleaneven though everything compiled before? - Clean removes the incremental compilation state and forces Gradle to rebuild the full task graph. Task ordering that worked with warm caches may not hold after clean, particularly around KAPT output registration and Kotlin/Native framework generation dependencies. The fix pattern involves explicitly declaring task dependencies in the build script rather than relying on implicit ordering.
-
How do I tell if a serialization failure is a plugin issue versus a runtime issue?
- Compile-time failures in the serialization plugin produce errors in the
:compileKotlinIosArm64or equivalent task. Runtime failures produceSerializationExceptionat the call site. Silent failures — where serialization succeeds but produces incorrect output — are the hardest: they require comparing serialized output against expected schemas per-target.
-
Is there a reliable way to force Gradle to invalidate all Kotlin compilation caches?
- Running
./gradlew clean --rerun-tasksforces task re-execution but doesn’t clear the remote build cache. To also invalidate remote cache hits, you need to either change a task input (such as adding a build parameter) or configurebuildCache { local { isEnabled = false } }temporarily. -
Why does KSP generate different output than KAPT for the same annotation processor logic?
- 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. Processor logic that works correctly in KAPT may need rewriting for KSP because the API surface differs significantly, not just the performance characteristics.
-
Can I use KAPT and KSP simultaneously in a multiplatform project?
- Technically yes, but the interaction is fragile. Both processors write to different output directories and run as separate Gradle tasks. If both process the same annotations, you’ll get duplicate generated classes that cause compilation errors. The typical pattern is to migrate processors one at a time, keeping KAPT only for processors that don’t have KSP equivalents yet.
-
Why does Detekt flag my inline functions differently on CI than locally?
- Detekt’s analysis depends on the Kotlin compiler version used during analysis. CI environments may use a different JDK, a different Detekt version pinned in CI config, or a different Kotlin language version flag than local development. Complexity metrics are particularly sensitive to compiler version because they’re computed from the AST, which changes with language version.
Conclusion
The failures documented above share a structural pattern: they emerge at the boundaries between tools that were designed independently and integrated at the build pipeline level rather than at the language level. KAPT predates Kotlin Multiplatform. KSP was designed for KMP but still has unresolved gaps around sealed class visibility and value class processing. The serialization plugin’s IR integration is solid for JVM but exposes native-specific gaps that only appear under specific type combinations. Gradle’s build cache invalidation logic was built for single-target projects and scales imperfectly to multi-target builds.
For junior and mid-level developers, the most reliable early warning signals are: iOS-only build failures after a plugin version bump (expect/actual or sourceSet visibility), clean build failures that don’t reproduce incrementally (KAPT output path registration), and serialization exceptions that appear on one platform but not another (plugin execution ordering or reflection fallback differences).
None of these patterns are documented in the official Kotlin Multiplatform guides, the KSP documentation, or the kotlinx.serialization readme. They’re community-discovered, Stack Overflow-documented, and YouTrack-tracked — often with open issues marked as “investigating” for multiple release cycles. Understanding why they happen, rather than just how to work around them, is what separates a developer who can debug a broken KMP build from one who re-creates the project hoping the problem disappears.
Written by: