Dependencies, klibs.io, cinterop and SwiftPM Import
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A Kotlin Multiplatform module rarely stands alone. It needs HTTP, serialization, a database, dependency injection and platform SDKs, and every one of those can come from a different ecosystem: a Kotlin Multiplatform library from Maven Central, an Android library from Google Maven, an Objective-C framework or a Swift package from the Apple world, or a C library. This page explains how each kind is declared, how Gradle picks the right artifact per target, and which of those dependencies are visible to the iOS app in the end.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4.1,
Gradle 9.8, Xcode 26.4, Ktor 3.6.0, SQLDelight 2.4.0, Koin 4.2.2, kotlinx-coroutines 1.11.0 and
kotlinx-serialization 1.11.0. SwiftPM import is Alpha; CocoaPods integration and Kotlin/Native Objective-C/C
interop are covered with their own stability notes below. The running example is TrailMate Everywhere, the
multiplatform version of the Android section’s TrailMate. Source sets are explained in
Project Structure, Targets, Source Sets and the
Hierarchy Template; the kotlin {} DSL in
Gradle Configuration, the Multiplatform DSL
and Compatibility. The Kotlin language itself is in the Kotlin Reference.
Two Kinds of Dependencies
The official documentation splits dependencies into two kinds, and the split drives where you declare them.
| Multiplatform dependencies | Native (platform) dependencies | |
|---|---|---|
What they are |
Libraries compiled for several targets, published with metadata that lets a common source set use them. |
Libraries from one platform’s own ecosystem: an AndroidX library, an Objective-C framework, a Swift package, a C library. |
Declared in |
|
|
Visible to |
All targets that depend on that source set |
Only code in that source set and the source sets that depend on it |
Examples in TrailMate |
Ktor client core, kotlinx-serialization, SQLDelight runtime, Koin core, Coil 3 |
Play Services Location, |
Managed by |
Gradle (and a version catalog) |
Gradle for Android and JVM; cinterop, CocoaPods or SwiftPM import for Apple |
The rule of thumb: put everything you can in commonMain, and add a platform-specific dependency only where common
code cannot reach a platform API itself. Platform dependencies are the hand-written side of the
expect/actual and platform API story.
Dependencies in commonMain and Platform Source Sets
Dependencies belong to a source set, not to the module as a whole. This is the main difference from a plain Android
module, where implementation(…) at the top level applies to everything. The recommended way to manage coordinates is
a Gradle version catalog, shown here for the shared module:
# gradle/libs.versions.toml
[versions]
kotlin = "2.4.20"
ktor = "3.6.0"
sqldelight = "2.4.0"
koin = "4.2.2"
coroutines = "1.11.0"
serialization = "1.11.0"
coil = "3.6.3"
ksp = "2.3.12"
room = "2.8.5"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
room-compiler = { module = "androidx.room3:room3-compiler", version = "3.0.3" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version = "9.4.1" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Those entries are then attached to source sets in the shared module’s build script:
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.sqldelight)
}
kotlin {
android {
namespace = "com.example.trailmate.shared"
compileSdk = 37
minSdk = 26
}
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
// Multiplatform: one coordinate, Gradle picks the right variant per target
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines)
implementation(libs.koin.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
androidMain.dependencies {
// Native: only the Android compilation sees these
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.android.driver)
}
iosMain.dependencies {
// Native: only iosArm64 and iosSimulatorArm64 see these
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native.driver)
}
}
}
Three details deserve emphasis.
-
The standard library is automatic. Every source set depends on
kotlin-stdlibat the version of the Kotlin Multiplatform Gradle plugin; for platform source sets Gradle uses the platform variant. -
kotlin("test")incommonTestis enough. It replaces the oldertest-commonandtest-annotations-commonartifacts, which are legacy. See Testing. -
Intermediate source sets work too. A dependency declared in
appleMainis visible toiosMain,macosMainand the other Apple targets, which is the right home for code shared between iOS and macOS.
The client engine pattern above is worth remembering because it recurs: the API artifact (ktor-client-core) goes
into commonMain, and the engine (ktor-client-okhttp, ktor-client-darwin) goes into the platform source sets.
The same shape applies to SQLDelight drivers and to many libraries that wrap a platform SDK. For the Android
counterpart of this setup see Project Structure, Gradle and Build
Variants.
Local projects and the Experimental top-level dependencies block
Dependencies on other modules of the same build use project(":other") in exactly the same source sets, for example
commonMain.dependencies { implementation(project(":sharedDomain")) }. Kotlin also offers a top-level
dependencies {} block inside kotlin {} that is Experimental; it is introduced in
Gradle Configuration, the Multiplatform DSL
and Compatibility. The source-set-scoped form shown above is the stable and universally documented one.
How Per-Target Variants Are Published and Resolved
A multiplatform library is published as several artifacts that Gradle treats as one logical module.
-
The root module (for example
io.ktor:ktor-client-core) carries Gradle Module Metadata (.modulefile). The metadata lists the available variants with attributes: which Kotlin platform (jvm,androidJvm,native,js,wasm), which native target, and which usage (API or runtime). -
Each target has its own target artifact:
ktor-client-core-jvm,ktor-client-core-iosarm64,ktor-client-core-iossimulatorarm64,ktor-client-core-wasm-jsand so on. JVM and Android variants are JARs or AARs; native variants are.klibfiles; JS and Wasm variants are.klibtoo. -
A common metadata artifact carries the declarations for shared source sets so that
commonMainand intermediate source sets can be compiled against the library without choosing a target.
When Gradle resolves commonMain.dependencies { implementation(libs.ktor.client.core) } it does this once for the
metadata compilation, and again for every target compilation, each time matching that compilation’s attributes to a
variant. You never write -iosarm64 yourself.
| Compilation | What gets resolved from the root coordinate |
|---|---|
|
The common metadata artifact (declarations only, no code generation) |
|
The |
|
The matching |
|
The |
|
The |
The practical consequences follow directly from this model.
-
A library must support every target you declare. If you add
wasmJs()and acommonMaindependency has no Wasm variant, resolution fails for that target. Check supported targets on klibs.io before adopting a dependency, or move it into a narrower source set such asnativeMain. -
A library that only publishes JVM artifacts cannot go in
commonMain. Retrofit, OkHttp, Room 2 (before its KMP support), Gson and Mockito are JVM or Android only and belong inandroidMainorjvmMain, usually behind an interface. -
Kotlin/Native
.klibfiles are not Java libraries. They are consumed by the Kotlin/Native compiler and linked into the framework; you cannot add a JAR toiosMain. -
Apple targets need a Mac to resolve and build. Declaring
iosArm64()on Linux still resolves metadata, but linking requires macOS; see How KMP Compiles.
Finding Libraries on klibs.io
https://klibs.io/ is the Kotlin Multiplatform library catalog. It indexes libraries published on GitHub and Maven Central, then tags and categorizes them by supported platforms so that you can filter for, say, "iOS and Wasm" before choosing. Each library page renders the project’s README and adds information such as the number of dependents, project activity and licenses.
Use it as a checklist when picking a dependency:
| Question | Where to look on klibs.io |
|---|---|
Does it support all my targets (Android, iOS, desktop, |
The platform filters and the per-library target list |
Is it maintained? |
Project activity and latest release |
Is it widely adopted? |
Number of dependents |
Is the license acceptable? |
License field |
What are the Maven coordinates and latest stable version? |
Library page (and the MCP server, below) |
The klibs.io MCP server
The team behind klibs.io publishes an MCP (Model Context Protocol) interface, so that a coding agent can query the
catalog directly instead of guessing coordinates from memory. The documented endpoint is https://api.klibs.io/mcp. The
server lets an agent verify supported targets, Maven coordinates, latest and latest stable versions, license,
maintenance and activity signals, and comparable alternatives. An agentic skill named kmp-libraries-expert is also
published to focus an agent on library selection.
The official page suggests adding guidance like the following to an AGENTS.md file, so every agent in the repository
chooses dependencies the same way:
<!-- AGENTS.md -->
## Kotlin Multiplatform library selection
When choosing or recommending Kotlin Multiplatform dependencies, use the klibs.io MCP server
(https://api.klibs.io/mcp) to access and filter a catalog of multiplatform libraries. Verify
supported targets, Maven coordinates, latest stable versions, license, and maintenance signals
before adding a dependency to commonMain.
Whatever an agent or a person finds, verify the version against Maven Central before pinning it in the version catalog, and keep the section’s other pages in mind: the libraries TrailMate actually uses are discussed in Essential Multiplatform Libraries, Networking with Ktor and Local Storage.
Android Dependencies
With the com.android.kotlin.multiplatform.library plugin, an Android dependency is declared the same way as any
other platform dependency: in the Android source set. The documentation recommends androidMain:
// sharedLogic/build.gradle.kts
kotlin {
sourceSets {
androidMain.dependencies {
implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.core:core-ktx:1.17.0")
}
}
}
When you migrate an Android module, a dependency such as debugImplementation(…) has no top-level equivalent: you add
an implementation dependency to the source set named androidDebug instead. To keep Android dependencies together and
avoid that confusion, the documentation also offers a dependencies {} block nested in the android {} block:
// sharedLogic/build.gradle.kts
kotlin {
android {
namespace = "com.example.trailmate.shared"
compileSdk = 37
minSdk = 26
dependencies {
// Treated exactly like androidMain.dependencies
implementation("com.google.android.gms:play-services-location:21.3.0")
}
}
}
The documentation strongly advises against an Android-style top-level dependencies {} block at the end of the script,
because mixing it with per-source-set declarations for other targets causes confusion. Under AGP 9 the plugin also has
no build types or flavors, so debugImplementation and friends are replaced by the androidDebug-style source sets
and by the androidApp module (see
The Android Target: the Android-KMP Library Plugin and AGP 9).
The Compose, Room and Play Services setup of a native app is in
Project Structure, Gradle and Build Variants.
iOS Dependencies
Apple SDK frameworks such as Foundation, UIKit, CoreLocation and Core Bluetooth are available to Kotlin/Native as prebuilt platform libraries, with no configuration at all (see expect/actual and Calling Platform APIs). Everything beyond Apple’s own SDKs, such as Firebase, Google Maps or a third-party framework, requires you to choose one of three mechanisms.
| Mechanism | Best for | Trade-off | Stability |
|---|---|---|---|
cinterop with a |
A C library, or an Objective-C library or framework you can build yourself; full control |
You write the definition file and the linker flags by hand |
Objective-C/C interop is Beta |
CocoaPods |
A pod already distributed through CocoaPods |
Needs the CocoaPods tooling and a Podfile; docs steer new work toward SwiftPM |
Stable, not deprecated |
SwiftPM import |
A Swift package that exposes an Objective-C API |
Not yet usable for exporting the KMP module as a Swift package itself |
Alpha |
A constraint applies to all three: Kotlin supports interoperability with Objective-C dependencies, and with Swift
dependencies only if their API is exported to Objective-C with @objc. Pure Swift APIs cannot be called directly from
Kotlin; wrap them in a small Swift adapter in the iOS app and inject it into shared code through an interface (see
the interface-with-platform-implementation pattern in
expect/actual). Swift export (Alpha) is the
opposite direction (Kotlin to Swift) and is covered in
Objective-C and Swift Interop.
cinterop and definition files
The Kotlin/Native cinterop tool reads C or Objective-C headers and generates Kotlin bindings. A definition file
(.def) tells it what to process. The workflow documented for a library is: download the dependency, build it to get
its binaries, write a .def file, and configure the build script to run cinterop and link the binaries.
A .def file for a hypothetical Objective-C date library vendored in TrailMate:
# sharedLogic/src/nativeInterop/cinterop/DateTools.def
language = Objective-C
headers = DateTools.h
package = DateTools
language = Objective-C is needed for Objective-C; omit it for pure C. For a library headers lists what to process,
while for a framework you use modules with the framework name instead:
# sharedLogic/src/nativeInterop/cinterop/TrailMapKit.def
language = Objective-C
modules = TrailMapKit
package = TrailMapKit
The Gradle side registers the interop on a compilation and tells the linker where the binaries live:
// sharedLogic/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.compilations.getByName("main") {
val DateTools by cinterops.creating {
// Optional when the file is named after the interop and sits in src/nativeInterop/cinterop/
definitionFile.set(project.file("src/nativeInterop/cinterop/DateTools.def"))
// Header search path, like the -I compiler option
includeDirs("vendor/DateTools/include")
}
}
target.binaries.all {
// Linker flags required to link the library binaries
linkerOpts("-Lvendor/DateTools/lib/${target.name}", "-lDateTools")
}
}
}
For a framework, use compilerOpts("-framework", "TrailMapKit", "-Fvendor/frameworks/") on the cinterop and the same
-framework and -F options in linkerOpts. Once built, import the generated package in iosMain:
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/platform/IosDateFormatter.kt
package com.example.trailmate.platform
import DateTools.* // generated from `package = DateTools`
import kotlinx.cinterop.ExperimentalForeignApi
@OptIn(ExperimentalForeignApi::class)
class IosDateFormatter {
fun timeAgo(epochSeconds: Long): String =
// NSDate.dateWithTimeIntervalSince1970 comes from the Foundation platform library;
// timeAgoSinceNow is an extension category provided by the generated DateTools binding
platform.Foundation.NSDate
.dateWithTimeIntervalSince1970(epochSeconds.toDouble())
.timeAgoSinceNow()
}
Key points about cinterop:
-
The generated bindings exist only for native targets, so they live in
iosMainorappleMain, never incommonMain. Share them with common code through an interface. -
Most cinterop declarations require
@OptIn(ExperimentalForeignApi::class), because the foreign-API surface is experimental in the language even though Objective-C interop itself is Beta. -
Header and linker paths are a common failure point. A
ld: library not foundorframework not founderror at link time meanslinkerOptsis missing or points at the wrong architecture slice (simulator versus device). -
Adding, upgrading and rebuilding the underlying binaries is entirely your job. This is why CocoaPods and SwiftPM import exist.
CocoaPods dependencies
The CocoaPods Gradle plugin lets a KMP module declare pods, and then generates the cinterop and the Podfile integration for you. Integration is Stable and CocoaPods is not deprecated, although the documentation now steers new projects toward direct integration or SwiftPM. A TrailMate module using one pod:
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
kotlin("native.cocoapods")
}
kotlin {
iosArm64()
iosSimulatorArm64()
cocoapods {
summary = "TrailMate shared logic"
homepage = "https://example.com/trailmate"
version = "1.0"
ios.deploymentTarget = "15.0"
framework {
baseName = "SharedLogic"
isStatic = true
}
// From the CocoaPods repository
pod("SDWebImage") {
version = "5.20.0"
}
}
}
A pod can come from the CocoaPods repository, from a local path, from a custom Git repository or Podspec repository, and with custom cinterop options. After editing, run Reload All Gradle Projects (or sync) and the plugin generates a podspec; the iOS app’s Podfile then points at the Gradle module. Pure Swift pods are not supported by this route, for the same Objective-C-only reason noted above. If you are moving an existing project off CocoaPods, the official documentation provides CocoaPods-to-SwiftPM migration guides; the two integration styles are compared in iOS Integration Options.
SwiftPM import (Alpha)
SwiftPM import is Alpha and may change incompatibly. It lets the Kotlin Gradle plugin import the Objective-C APIs of Swift packages declared in the Gradle build, for your Apple targets. For transitive consumers (a module that depends on a module using SwiftPM import) the plugin supplies the required machine code automatically, so linking a framework or running Kotlin/Native tests needs no extra configuration. The documentation points to a specific Kotlin Gradle plugin version to try; check the page referenced below for the version it currently asks for, since this feature is evolving between releases. Exporting a KMP module that itself uses SwiftPM import as a Swift package is not yet supported.
Packages are declared in a swiftPMDependencies {} block inside kotlin {}:
// sharedLogic/build.gradle.kts
kotlin {
iosArm64()
iosSimulatorArm64()
swiftPMDependencies {
// Import FirebaseAnalytics into Kotlin code
swiftPackage(
url = url("https://github.com/firebase/firebase-ios-sdk.git"),
version = from("12.5.0"),
products = listOf(product("FirebaseAnalytics")),
)
// Transitive Firebase dependency: only list it to pin a specific version
swiftPackage(
url = url("https://github.com/apple/swift-protobuf.git"),
version = exact("1.32.0"),
products = listOf(),
)
}
}
The version requirement uses from(…) (up to the next major) or exact(…). Import works on Clang modules: by
default, all Clang modules found in the declared packages become visible to Kotlin. Set
discoverClangModulesImplicitly = false to turn that off; the product names are then used as module names, and you
list modules whose names differ from the product with importedClangModules. When you add other targets such as macOS,
constrain a dependency that only supports iOS with the platforms parameter:
// sharedLogic/build.gradle.kts
kotlin {
iosArm64()
iosSimulatorArm64()
macosArm64()
swiftPMDependencies {
swiftPackage(
url = url("https://github.com/googlemaps/ios-maps-sdk.git"),
version = exact("10.3.0"),
products = listOf(
// Visible to iOS compilations only
product("GoogleMaps", platforms = setOf(iOS()))
),
)
}
}
The first time you add a SwiftPM dependency you must link the Xcode project with the synthetic package that the plugin generates. Run the integration task once, passing the path to the project, then commit the generated package and the updated Xcode project:
# from the TrailMate repository root
XCODEPROJ_PATH='iosApp/iosApp.xcodeproj' ./gradlew :sharedLogic:integrateLinkagePackage
After that, the package updates itself whenever you change the set of dependencies or their versions. The plugin also
creates Package.resolved lock files, merged by default into one file inside the synthetic package’s
.swiftpm-locks directory, so commit it to keep builds reproducible. Imported Objective-C APIs live in a namespace
starting with swiftPMImport. followed by the Gradle group and project name:
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/analytics/IosAnalytics.kt
package com.example.trailmate.analytics
// Gradle group "com.example.trailmate", project "sharedLogic" -> segments appear in the package
import swiftPMImport.com.example.trailmate.sharedLogic.FIRAnalytics
import kotlinx.cinterop.ExperimentalForeignApi
@OptIn(ExperimentalForeignApi::class)
class IosAnalytics : Analytics {
override fun logTrailOpened(trailId: String) {
FIRAnalytics.logEventWithName("trail_opened", parameters = mapOf("trail_id" to trailId))
}
}
Which mechanism should a new TrailMate dependency use?
| Situation | Choice |
|---|---|
A Swift package with an Objective-C API, and you accept Alpha |
SwiftPM import |
A pod, and the team already uses CocoaPods |
CocoaPods |
A vendored C or Objective-C library |
cinterop with a |
A Swift-only SDK |
A Swift adapter in |
KSP in Multiplatform Projects
Kotlin Symbol Processing (KSP) generates code for libraries such as Room, Koin Annotations and Metro. On Kotlin/Native
the older kapt is not available, so KSP is the route for annotation processing. In a multiplatform module the processor
dependency is added per target, through configurations named ksp<Target>, because each target is compiled
separately. Since KSP 2 the catch-all ksp(…) configuration is deprecated; configure each target explicitly so that
processors do not run where they are not needed.
The shape for TrailMate with Room 3’s compiler (the processor library name is the one from the Room KMP page; use the coordinates listed there):
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.ksp)
}
dependencies {
// Processors are added to a configuration per target, not to a single `ksp`
add("kspAndroid", libs.room.compiler)
add("kspIosArm64", libs.room.compiler)
add("kspIosSimulatorArm64", libs.room.compiler)
}
The processor path can also be a project, add("kspAndroid", project(":local-processor")), and a target may have
several processors. When many iOS targets share the same processor you can loop over them:
// sharedLogic/build.gradle.kts
dependencies {
kotlin.targets
.filter { it.name.startsWith("ios") }
.forEach { target ->
add(
"ksp${target.name.replaceFirstChar { it.uppercaseChar() }}",
libs.room.compiler,
)
}
}
Each target and compilation (main and test) gets its own symbol-processing task, created only where a processor
configuration exists. To run processors in tests, add them to the test configurations: kspIosArm64Test, kspAndroidHostTest
or kspAndroidDeviceTest. To list every KSP configuration your module has, run:
./gradlew :sharedLogic:dependencies | grep ksp
The configuration kspCommonMainMetadata is the one libraries use for code that must be generated into the common
source set (the metadata compilation), for example factories or DI components that common code calls directly. Whether
a processor supports it is a property of that processor, so follow its own documentation: Room and Koin Annotations
each describe which ksp<Target> and kspCommonMainMetadata entries they need. If common code references a
generated class and you only configured the platform configurations, the metadata compilation cannot see it, which
shows as an "unresolved reference" in commonMain while platform builds succeed. See
Dependency Injection with Koin and Metro and
Local Storage for per-library
configurations, and Project Structure, Gradle and Build Variants
for the Android-only KSP and kapt migration.
api versus implementation and What Reaches the iOS Framework
Each source set has the usual Gradle configurations, and the choice matters more than in an Android module because of the iOS framework.
| Configuration | Meaning in a KMP source set |
|---|---|
|
The dependency is used by this module’s own code. It is not on the compile classpath of modules that depend on this one, and its types should not appear in your public API. |
|
The dependency is part of this module’s public API and is exposed transitively to consumers' compile classpath. |
|
Used for annotation-only or runtime-only artifacts. Not available in every source set. |
For a Kotlin consumer, api simply means "consumers can see these types". For iOS the picture is different, because a
Kotlin/Native framework is a closed binary with an Objective-C header. Only the declarations you choose to export
appear in that header, regardless of api or implementation:
-
The framework always contains the compiled code of every dependency it needs, whether declared
apiorimplementation, linked into the binary. -
The Objective-C header contains only the shared module’s own public API by default.
-
Types from a dependency appear in the header only if you list that dependency with
export(…)on the framework binary. Dependencies not exported show up as opaqueKotlinBase-style types or are missing, so Swift cannot use them directly. -
To export a dependency, it must be an
apidependency of the module, and you add it to the framework’sexport.
// sharedLogic/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedLogic"
isStatic = true
// Make kotlinx-datetime types usable from Swift, not just from Kotlin
export(libs.kotlinx.datetime)
}
}
sourceSets {
commonMain.dependencies {
// `api` so that export() above is allowed
api(libs.kotlinx.datetime)
// internal detail, never seen by Swift
implementation(libs.ktor.client.core)
}
}
}
In Swift, an exported dependency’s types are then usable through the framework’s module:
// iosApp/iosApp/TrailListView.swift
import SwiftUI
import SharedLogic
struct TrailListView: View {
// `Kotlinx_datetimeInstant` is generated because kotlinx-datetime is exported
let lastSync: Kotlinx_datetimeInstant?
var body: some View {
Text(lastSync.map { "\($0.epochSeconds)" } ?? "Never synced")
}
}
The decision table below gathers the practical guidance.
| Dependency | Use | Reason |
|---|---|---|
Ktor, SQLDelight, Koin internals |
|
Implementation details; keep them out of the iOS header to limit binary size and API surface |
A type that appears in a shared public signature (for example a |
|
Swift needs the real type, not an opaque one |
Platform engine ( |
|
Selected at runtime; Swift never touches it |
Test libraries |
|
Never ship in the app |
Exporting is not free: every exported dependency enlarges the header and the exposed API, so export the minimum, or wrap types in your own DTOs (see Objective-C and Swift Interop). Sharing two modules into one iOS app is a related case: each module producing its own framework duplicates the Kotlin runtime and dependencies, so the usual answer is an umbrella framework that exports both; see Project Structure, Targets, Source Sets and the Hierarchy Template and iOS Integration Options.
Common Mistakes
| Mistake | Fix |
|---|---|
Adding |
Engines are platform-specific: OkHttp in |
Adding a JVM-only library (Retrofit, Gson) to |
Move it to |
Hard-coding target suffixes like |
Declare the root coordinate; Gradle resolves variants from module metadata. |
Using the legacy |
SQLDelight 2.x uses the |
Using the deprecated catch-all |
Use |
Expecting a pure Swift package to be importable |
cinterop, CocoaPods and SwiftPM import expose Objective-C APIs; wrap Swift-only SDKs in an |
Forgetting the Xcode link step after the first SwiftPM dependency |
Run |
Using |
Declare it |
Referencing generated code from |
Add |
Declaring |
Use |
Summary
-
Dependencies belong to source sets: multiplatform libraries in
commonMain, platform libraries inandroidMainandiosMain. -
Gradle Module Metadata lets a single coordinate resolve to the right variant for each target.
-
klibs.io, and its MCP server, help you confirm that a library supports your targets and is maintained.
-
Android dependencies go in
androidMainor the nestedandroid { dependencies {} }block. -
iOS dependencies come through cinterop
.deffiles, CocoaPodspod()or SwiftPM import (Alpha); only Objective-C-visible APIs are importable. -
KSP is configured per target, with
kspCommonMainMetadatafor common-source generation where supported. -
Use
implementationfor internals andapiplusexport(…)only for types Swift must see.