KMP in practice, Part 3: The data layer
A Room crash that compiled fine, and why our auth is a plain function instead of a Ktor plugin.
Part 3 of KMP in practice. Missed the last one? Part 2 covers the interface-only module and why we left Hilt:
The data layer was the first thing we shared, so it’s also where the first surprises showed up. Two of them: storage with Room KMP, and networking with Ktor.
Room KMP: the crash that compiled fine
Room supports Kotlin Multiplatform since 2.7. After we moved :storage to the AGP 9 KMP library plugin, everything built and every test passed. Then the app crashed on launch:
Cannot find implementation for WeatherDatabase. WeatherDatabase_Impl does not existWhat was going on
We were still on Room 2.5. Before 2.7, Room’s KSP processor generates Java for the _Impl class. The KMP Android plugin compiles androidMain as Kotlin only, with no javac step, so that Java never made it into the artifact the app uses.
The abstract WeatherDatabase compiled fine, so nothing failed at build time. Old _Impl classes left in build/ from earlier builds made it even harder to spot.
The fix was upgrading to Room 2.7+ (we’re on 2.8.4), which generates Kotlin. To check, look for WeatherDatabase_Impl.class in the module’s androidMain classes jar, not only in a debug variant folder.
What the shared database looks like
@Database(entities = [WeatherEntity::class, AirQualityEntity::class], version = 6)
@ConstructedBy(WeatherDatabaseConstructor::class)
abstract class WeatherDatabase : RoomDatabase() {
abstract fun weatherDao(): WeatherDao
}
expect object WeatherDatabaseConstructor : RoomDatabaseConstructor<WeatherDatabase>On iOS, the builder uses the bundled SQLite driver and the same migration objects as Android:
Room.databaseBuilder<WeatherDatabase>(name = documentDirectory() + "/$WEATHER_DATABASE_NAME")
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.fallbackToDestructiveMigration(false)
.build()Three habits that came out of this:
- Migrations are shared code. Our last schema change added the place a cached forecast belongs to. It’s written once and runs on both platforms.
- Test migrations where they actually run. Our migration test lives in
iosTestand runs on the simulator, against the real bundled driver. - Turn off destructive fallback explicitly. For an offline-first weather app, silently wiping the cache is worse than a loud failure.
Networking: auth as a plain function
Ktor replaced Retrofit, with OkHttp on Android and Darwin on iOS. The client was the easy part. The tricky part was attaching auth and retrying on 401.
Get Ankush Bose’s stories in your inbox
Join Medium for free to get updates from this writer.
We started with Ktor’s auth plugin, then replaced it with a function you can read top to bottom:
suspend fun HttpClient.authorizedRequest(tokenManager: TokenManager, block: ...): HttpResponse {
val firstToken = tokenManager.getStoredToken()
val response = block(authHeader(firstToken, tokenManager.authorizationScheme()))
if (response.status != HttpStatusCode.Unauthorized) return response
// 401 only. At most one refresh and one replay, so it can't loop.
return when (val result = tokenManager.handleUnauthorized(rejectedToken = firstToken)) {
is TokenResult.Valid -> block(authHeader(result.token, tokenManager.authorizationScheme()))
else -> response // simplified: the real code also ends the session where needed
}
}Why a function, not the plugin
- You can see the whole retry policy. It refreshes once and replays once, so it can’t loop. It only reacts to 401, so a 403, like a wrong password when deleting an account, never logs the user out.
- The token type can change between calls. A refresh can switch the session from Bearer to DPoP, so the replay reads the scheme again. In a function that’s one visible line. In plugin config it’s easy to miss.
- Parallel 401s refresh once.
handleUnauthorizedruns under aMutex. The first caller refreshes, and the rest see the token has changed and reuse the new one.
Only small pieces stay platform-specific: the HTTP engine, the connectivity check, and SHA-256 (an expect fun backed by MessageDigest on Android and CommonCrypto on iOS).
The one thing to take away
A green build doesn’t prove generated code made it into the artifact. When you move a module to the KMP plugin, check what it actually publishes, not just whether it compiles.
Where did I go wrong?
Some of you will say we gave something up by dropping Ktor’s auth plugin. If so, tell me what we missed in the comments. Same if you’ve found a better way to test Room migrations in KMP.
Next in the series: Part 4, MVI with a pure reducer, a Flow-based domain and the SwiftUI bridge:
All five parts, and the decision behind each one, in one place:






