commonMain sees only the Kotlin stdlib, so java.* types are gone: use kotlinx-datetime with kotlin.time.Clock/Instant for dates, kotlinx-io for files, Mutex for locks and kotlin.uuid.Uuid for UUIDs.
Date and time: kotlinx-datetime for LocalDate and TimeZone; Instant and Clock are stdlib, stable since Kotlin 2.3.
Elapsed time: TimeSource.Monotonic.markNow(), not two wall-clock reads that an NTP correction can turn negative.
Files: kotlinx-io: Path, SystemFileSystem, Source, Sink.
Locks: a coroutine Mutex or stdlib atomics instead of synchronized.
AndroidMiddle
KMP Shared Module in Practice
Ktor, kotlinx.serialization, SQLDelight, Koin, source sets, Gradle, multiplatform tests
Questions closed
0 / 21
- 0Review
- 2Asked often
Questions
It builds a new HttpClient on every call and never closes it, so each request pays for a fresh engine (new connection pool, new TCP and TLS handshake), and the old clients leak their resources.
No connection reuse: keep-alive and HTTP/2 multiplexing live inside the engine (OkHttp's pool, Darwin's NSURLSession), so a throwaway client throws them away too.
A leak: an unclosed client keeps its engine and its coroutine scope alive; on the JVM that can include the engine's threads.
Config drift: every call site repeats the plugin setup, and the one that forgets HttpTimeout or the auth plugin behaves differently.
The fix: one client for the app, injected into the repositories, and close() only when the app or the user session ends. HttpClient is safe to share between coroutines.
Injecting the client also makes the repository testable: commonTest passes a client built on MockEngine.
HttpClient is only an API surface; the HTTP work is done by a per-platform engine, and its artifact must be declared in each platform source set.
Android: ktor-client-okhttp.
iOS and macOS: ktor-client-darwin, a wrapper around NSURLSession.
JVM desktop: ktor-client-cio or OkHttp.
Tests: ktor-client-mock in commonTest; commonMain gets only ktor-client-core plus the plugins.
A forgotten engine: HttpClient { } with no engine looks it up on the classpath when the client is created. Compilation passes, but construction throws IllegalStateException: Failed to find HTTP client engine implementation: consider adding client engine dependency, and with a lazily created client this looks like a first-request crash. Android CI is green, the iOS app dies on its first screen.
Two styles: rely on that discovery, or declare expect fun httpClient(): HttpClient and return HttpClient(Darwin) and HttpClient(OkHttp) from the actuals: a missing target then fails at compile time and engine-specific settings get a home.
body<T>() only deserializes; deciding that a response is an error is a separate job that Ktor does not do by default, so a 502 has to become a typed error before any parsing.
**One Json**: build it once in commonMain and pass it to install(ContentNegotiation) { json(appJson) }. ignoreUnknownKeys = true is mandatory in a shipped app.
Sending: setBody(dto) needs contentType(ContentType.Application.Json); without it the request fails before sending with an IllegalStateException about the body type and a null Content-Type.
Why the 502 explodes: expectSuccess defaults to false, so a 502 is an ordinary response and execution walks into deserialization. No converter handles text/html, so you get NoTransformationFoundException (an UnsupportedOperationException): its message names the URL, the 502 and text/html, but it is a deserialization failure no UI can branch on.
Where it belongs: expectSuccess = true makes non-2xx throw ClientRequestException (4xx) or ServerResponseException (5xx) before parsing. Translate every case, JsonConvertException for a wrong-shaped body included, once in HttpResponseValidator { handleResponseExceptionWithRequest { … } } into a sealed domain error, so both apps branch on one Kotlin type.
"/users/me" starts with a slash, so defaultRequest treats it as an absolute path that replaces the base path: the call goes to https://api.example.com/users/me and loses /v2/, while the relative "feed" still lands on /v2/feed.
How the merge works: defaultRequest resolves each request URL against the base the way a browser resolves a link: a path starting with / replaces the base path, a relative one is appended to the base's last directory.
The twin bug: with url("https://api.example.com/v2") and no trailing slash, v2 counts as a file name and is dropped, so even "feed" goes to /feed. Rule: the base ends with /, request paths never start with one.
Why it hides: with the default expectSuccess = false the 404 is an ordinary response, so the app sees a deserialization error from body() rather than a clear "not found".
Catching it: a commonTest on MockEngine that asserts request.url.encodedPath for every endpoint. Logging at LogLevel.INFO prints the full URL of every call.
It prints {"amount":5,"note":"sale"}: currency is dropped because it equals its default, note is kept because it differs from its initializer, isFree is skipped because it is only a getter.
Defaults are not encoded: Json omits a property whose value equals its default (encodeDefaults = false). An untouched body property with an initializer is dropped too, so Price(5) alone prints {"amount":5}. A backend that requires currency now rejects every USD price. Fix it with @EncodeDefault on that property (still an experimental API) or encodeDefaults = true on the shared Json.
Body properties count: the plugin serializes every property with a backing field, including ones declared in the class body, so note is written once it changes. Mark it @Transient (the kotlinx.serialization one, not kotlin.jvm.Transient, which the plugin ignores) or make it a getter.
Getters do not: isFree has no backing field, so it never appears on the wire and never has to be decoded.
Decoding mirrors this: a missing currency decodes to "USD"; a missing amount throws MissingFieldException.
Install Ktor's Auth plugin with a bearer { } provider: loadTokens supplies the stored pair, and on a 401 Ktor calls refreshTokens, retries the request once with the new token, and passes the 401 through if refresh returns null.
**loadTokens runs once and its result is cached in memory; read from a Keychain/Keystore-backed store there.
refreshTokens** gets oldTokens and the failing response. Call the refresh endpoint with markAsRefreshTokenRequest() so it skips the auth flow, persist the new pair, return it.
Parallel 401s do not each spend the refresh token: one refresh runs at a time, the other requests wait for it and retry.
A failed refresh returns null; the 401 reaches your response validator, which maps it to Unauthorized, and the app logs out. On logout also call clearToken() on the provider, or the next user starts with the cached token.
Other hosts: bearer sends the token pre-emptively, including to a CDN; restrict it with sendWithoutRequest { it.url.host == apiHost }.
Logs: Logging at LogLevel.HEADERS prints Authorization unless you add sanitizeHeader { it == HttpHeaders.Authorization }.
Model it as a @Serializable sealed hierarchy: the compiler plugin registers the subclasses for you, and Json writes and reads a class discriminator (type by default) to tell them apart.
Sealed base: a sealed interface or sealed class annotated @Serializable, with every subclass @Serializable too.
**@SerialName("post") on each subclass. Without it the discriminator value is the fully qualified class name, so moving or renaming the class silently changes your wire format.
Another key**: @JsonClassDiscriminator("kind") (@ExperimentalSerializationApi) per hierarchy, or classDiscriminator on the Json globally. A subclass property literally called type collides with the discriminator, and Json rejects the hierarchy on first use.
Open polymorphism: a non-sealed base must be registered by hand in a SerializersModule with polymorphic(Base::class) { subclass(Impl::class) }. Nothing scans the classpath and Kotlin/Native has no Class.forName, so an unregistered subtype is a SerializationException at runtime on every target.
SQLDelight generates plain blocking code, the driver is the only per-platform piece, and every generated call runs on whatever thread you called it from.
Android: AndroidSqliteDriver(AppDatabase.Schema, context, "app.db") over SupportSQLiteOpenHelper; the file lands in /data/data/<pkg>/databases/.
iOS and other native targets: NativeSqliteDriver(AppDatabase.Schema, "app.db") over SQLiter, which uses the Documents directory by default; move a rebuildable cache to Application Support (inside Library) via onConfiguration.
JVM tests: JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY), and you call AppDatabase.Schema.create(driver) yourself: no open helper does it for you.
Wiring: expect class DriverFactory, or build the driver in each app and inject it.
The thread: executeAsList(), executeAsOne() and transaction { } all block. There is no Room-style runtime guard against main-thread queries and no StrictMode on iOS, and an in-memory JVM test is too fast to reveal it. Wrap reads and writes in withContext(Dispatchers.IO) (it exists on Kotlin/Native too) and keep transactions short: SQLite serialises writers.
A .sq file describes the schema a fresh install gets; upgrading an existing database is a separate .sqm file, so editing only the .sq leaves old installs with old columns and new queries that select from them.
**.sq**: the current CREATE TABLE statements plus labelled queries: always the destination state, never a history.
**.sqm: one migration per file, named for the version you migrate from**: 1.sqm takes a v1 database to v2. AppDatabase.Schema.version is the highest migration number plus one.
At runtime the driver runs Schema.create on a brand-new file and Schema.migrate(old, new) on an existing one; AfterVersion(n) { driver -> … } hangs a data backfill off a specific step.
Making the build catch it: set schemaOutputDirectory and verifyMigrations = true in the sqldelight { databases { … } } block, run generate<SourceSet><Name>Schema to snapshot <version>.db and commit it. verifySqlDelightMigration replays every .sqm against the snapshots and fails when the result differs from the .sq; it hooks into check, so CI already runs it.
asFlow() registers a Query.Listener for the tables the query reads, and SQLDelight notifies those listeners only when a write goes through the generated API on the same driver.
Pipeline: queries.selectAll().asFlow().mapToList(io) from app.cash.sqldelight:coroutines-extensions. asFlow() emits Query objects, not rows; mapToList executes them on the CoroutineContext you pass, so pass Dispatchers.IO, never the main dispatcher.
Emissions: once on collection, then after every notifying write.
Granularity is the table, not the row: any insert into player re-runs every query reading player, so add .distinctUntilChanged().
Transactions: inside transaction { } notifications wait for the outermost commit, so twenty inserts give one emission.
Silent failures: a statement executed straight on the driver (raw SQL) tells the registry nothing about which tables changed, which is the usual cause of a frozen list. A second driver over the same file has its own in-memory registry, so the two never hear each other.
Swift never talks to Koin directly: you start the container from a top-level Kotlin function the iOS app calls once, and expose typed accessors, because get<T>() is inline reified and reified generics do not survive the Objective-C bridge.
Modules: commonMain declares them; anything platform-shaped (SQLDelight driver, secure storage, Ktor engine) goes behind expect fun platformModule(): Module.
One entry point: fun initKoin(extra: KoinAppDeclaration = {}) = startKoin { extra(); modules(sharedModule, platformModule()) } is the single definition of "the graph". Android calls it from Application.onCreate with androidContext(this).
iOS start: a no-argument wrapper such as fun startShared() = initKoin {}, called once from the App initializer (a second startKoin throws KoinAppAlreadyStartedException), because Swift sees neither default arguments nor the KoinAppDeclaration lambda. Avoid an init prefix: Objective-C reserves it, so Swift would see doInitKoin().
Resolving: get<T>() is inlined at the call site and never reaches the generated header. Resolve on the Kotlin side: a Kotlin object implementing KoinComponent with one plain function per thing iOS needs, so Swift writes Deps.shared.feedViewModel() and gets a concrete class.
A container buys you lifetimes, one override point, and a lookup the iOS side can call without spelling out a twelve-argument constructor; until you need those, hand-written factories are a legitimate DI story and cheaper to read.
Manual wiring gives you constructor injection checked by the compiler: a missing dependency is a build error, not a runtime NoDefinitionFoundException.
No extras: no klib, no reflection, nothing new for the iOS team; fakes in commonTest are trivial because you already pass everything in.
The cost of manual is bookkeeping: every singleton is a by lazy you maintain by hand, and the platform still feeds in the parts only it can build (SQLDelight driver, Ktor engine, Keychain wrapper).
A container adds named lifetimes (single, factory, session or screen scopes), one place to swap an implementation (a test module overriding FeedApi), and a resolution entry point that Swift reaches through a small Kotlin facade.
The price: wiring errors move to first use. Koin 4.x can verify a module in a test, but only on the JVM; it does not protect the iOS build.
Alternatives: kotlin-inject (KSP) and Metro (a compiler plugin) check the graph at compile time.
multiplatform-settings is the right default for preferences and the wrong home for a token, because the guarantees a secret needs cannot be expressed by putString(key, value).
What it is: a common Settings interface over SharedPreferencesSettings and NSUserDefaultsSettings, plus an experimental KeychainSettings. None of the standard backends encrypts anything: NSUserDefaults is a plist, shared prefs are XML on disk.
Keychain accessibility: an item has a class, such as kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly versus the default, which is restored onto a new device from a backup. A key-value API has nowhere to say that.
Reinstall: Keychain items outlive app deletion, so a "logged out" user can come back logged in unless you clear on first run.
Android: Jetpack Security's EncryptedSharedPreferences is deprecated; the durable answer is a Keystore-backed key that encrypts the token, with the ciphertext in DataStore.
API shape: Keychain calls can fail and block, so you want suspend and a real error, not a synchronous getter returning null for both "absent" and "device locked".
The design: a TokenStore interface in commonMain, implemented per platform, injected, faked in commonTest; boring keys stay in Settings.
expect/actual is a compile-time switch with no seam: it binds one name to exactly one implementation per target, so anything you might want to fake, configure or swap belongs behind an interface instead.
Good for tiny leaves with one obvious implementation and nothing to test: a device model string, the current locale tag.
**actual typealias maps an expected declaration onto a type the platform already has, with zero wrapper code.
Completeness** is enforced: add a target and the build fails until every actual exists, which is what you want for a leaf.
No fake: commonTest cannot supply an actual, so a common test over an expect really runs the JVM implementation, and the iOS one is covered only by an iOS test run.
One implementation per target: no in-memory variant, no per-flavour choice, no runtime decision.
**expect class** is still beta: the compiler warns unless you pass -Xexpect-actual-classes, and members must match exactly, so a refactor hurts in three places at once.
What scales is an interface in commonMain, implementations in androidMain and iosMain, and one choice made where the app is assembled: DI, or a single expect fun factory.
The Kotlin Gradle plugin generates them from the targets you declare, through the default hierarchy template, so the target list defines the tree, and adding one silently changes what the shared parents have to compile for.
The template: applyDefaultHierarchyTemplate() creates the intermediate sets (nativeMain, appleMain, iosMain, watchosMain) above the per-target leaves, and the plugin applies it for you as long as you never write a dependsOn edge by hand.
Groups need targets: iosMain exists because you declared iOS targets, not because a folder with that name is on disk; a directory the template did not create is not part of any compilation.
**Explicit dependsOn: write one and the template steps aside for the whole project; the tree is yours to maintain.
UIKit**: appleMain now compiles for watchOS too, and watchOS has no UIKit, so Apple-shared code reaching for UIDevice has to move down into iosMain.
**expects**: every one in commonMain needs an actual covering the new leaf.
Dependencies: every one declared in an ancestor set must publish a klib for the new target, or resolution fails there.
Swap the network for Ktor's MockEngine in commonTest, install HttpRequestRetry with exponentialDelay(randomizationMs = 0) and a delay { } hook, then assert on the recorded delays: the test never waits and the numbers are deterministic.
Fake engine: ktor-client-mock is multiplatform, so the fake lives in commonTest and the same test runs on the JVM and on the simulator.
Every attempt: MockEngine { request -> ... } is called for each attempt, retries included. Three retries means four engine calls: answer respondError(HttpStatusCode.ServiceUnavailable) three times, then a real body.
Success response needs headers = headersOf(HttpHeaders.ContentType, "application/json"), or ContentNegotiation refuses to deserialize it and you debug the wrong failure.
Virtual waiting: the plugin delay runs in the caller's coroutine, so runTest makes it virtual instead of sleeping for seven real seconds.
Deterministic assertion: exponentialDelay adds random jitter by default; randomizationMs = 0 plus the delay { waited += it } hook let you assert exactly listOf(1000L, 2000L, 4000L).
Never assert timing with a real clock or a withTimeout guard; a test that passes because the machine was fast fails on CI.
HttpRequestRetry here retries every method, so a POST /payments that got a 503 or a dropped connection after the server had already charged the card is sent again: up to four times in total, and each attempt can be a new charge.
A 5xx does not mean "nothing happened": a gateway can answer 503 while the backend finishes the charge, and a connection lost after the request left the device says just as little. retryOnExceptionOrServerErrors treats both as safe to repeat, whatever the method.
Which methods are safe: GET, HEAD, PUT and DELETE are idempotent by HTTP semantics: repeating them leaves the same state. POST and PATCH are not. The fix limits the global policy with retryIf and retryOnExceptionIf on request.method.
When a POST must be retried: send an Idempotency-Key the server stores with the result, so a repeat returns the first result instead of charging again. The key is made once per logical operation (per order or checkout tap), never per attempt; then that one request opts in with retry { }.
Cancellation: a custom retryOnExceptionIf replaces the default predicate, which skips CancellationException and, by default, timeouts; keep the cancellation check and decide on timeouts deliberately.
Catching it: a commonTest on MockEngine that answers 503 to a POST and asserts the engine was called exactly once.
On Android the shared code is ordinary JVM bytecode and Crashlytics deobfuscates it from your R8 mapping file; on iOS it is native machine code, so the report is only as readable as the dSYM you uploaded, and by default the Kotlin exception itself never reaches the reporter.
Android
Shared classes are in the same DEX as the app, so an uncaught exception arrives with its Kotlin frames intact.
The Crashlytics Gradle plugin uploads the R8 mapping automatically; if someone turned mappingFileUploadEnabled off for speed, every release build after that is unreadable.
Handled failures in shared code go through recordException, which is a non-fatal, not a crash.
iOS
Kotlin/Native emits a .dSYM next to the framework. With isStatic = true the symbols end up in the app's own dSYM instead; either way that file has to be uploaded, from the archive, by the Crashlytics run script or firebase crashlytics:symbols:upload. A CI job that discards the archive is the usual reason for hex frames.
Release binaries carry only light debug info; -Xadd-light-debug=enable is the switch that keeps function names in the trace.
An uncaught Kotlin exception crossing to Objective-C terminates the process, and the report blames the Kotlin/Native runtime rather than your IllegalStateException. Install setUnhandledExceptionHook, report stackTraceToString() yourself, then call terminateWithUnhandledException so the crash still lands.
Practically: define one CrashReporter interface in commonMain, implement it with Crashlytics on Android and hand in a Swift implementation on iOS (or use Sentry's KMP SDK, which ships both). Treat the Kotlin-side hook as the source of truth and the native crash report as corroboration.
A KMP publish is not one artifact but one module per target plus a root module whose Gradle metadata points at them, and none of it helps an Xcode consumer, who needs an XCFramework published alongside.
What comes out
A root shared module with a .pom and a .module file listing every variant. Consumers write one coordinate and Gradle resolves shared-jvm, shared-android or shared-iosarm64 per target.
Per-target modules carrying the real artifacts: jars for JVM and Android, .klib for each native target, plus sources.
klibs are readable only by a compatible Kotlin compiler, so your Kotlin version is part of your public contract; say it in the release notes and bump deliberately.
The iOS app team consumes none of that. They take an .xcframework, as an SPM binary target with a checksum or as a Pod, built in the same release job.
What a break costs
JVM and Android consumers recompile against a new jar, so a @Deprecated shim gives them a migration window.
The iOS consumer compiles Swift against generated Objective-C headers. A rename, or a parameter added to an existing function, changes the selector: a source break with no source of yours to patch. Kotlin's @Deprecated does export (WARNING becomes deprecated, ERROR and HIDDEN become unavailable), and it is the only warning they get.
Anything internal never reaches the header, and @HiddenFromObjC removes a public declaration from it; a small exported surface is what keeps you free to refactor.
Gate it in CI with a committed ABI dump (the binary-compatibility-validator, or the Kotlin Gradle plugin's own ABI validation) so a removal fails your build instead of theirs.
Gradle resolves a separate compile classpath per target, so a JVM-only dependency fails at resolution for the native target ("no matching variant", listing the attributes it wanted) long before anything is compiled or linked.
Reading the failure
The message prints the variants the library does publish (apiElements, org.jetbrains.kotlin.platform.type = jvm) against what the consumer asked for (native, plus org.jetbrains.kotlin.native.target = ios_arm64). That is the whole diagnosis: it ships no klib for your target.
A transitive offender looks identical except the coordinate named is not the one you added. A multiplatform library pulling in a JVM-only dependency for its JVM variant only is fine; one doing it from common code is not.
To know before you type the coordinate: look at the library's .module metadata on Maven Central, or search klibs.io, which indexes multiplatform libraries by target. ./gradlew :shared:dependencies --configuration iosArm64CompileKlibraries shows what a native target actually resolves.
Real link failures read differently: Undefined symbols for architecture arm64 from a missing cinterop or framework, not a resolution error. If Gradle got that far, the dependency was fine.
Your options
Keep it out of commonMain: an interface in common, the SDK wired in androidMain, an iOS implementation from the platform side. Say this one first; it is usually right anyway.
Replace it with a multiplatform equivalent: Ktor for OkHttp, kotlinx-datetime for a JVM date library, Kermit for Timber.
Port it, if it is a small pure algorithm rather than a platform integration.
Leave the feature native on iOS and share only the model.
Not options: an iosMain stub that throws (it compiles and then ships), or switching implementation to api, since variants are attribute-matched, not scope-matched. The invariant to state out loud: commonMain may depend only on libraries publishing a klib for every target you build.