- Explain how the repo creates the HttpClient in commonMain and binds the Ktor engine per platform, including how supabase-kt picks its engine
- Use DataStore in KMP without losing existing users' data, and understand why each store must be a single instance
- Choose between DataStore, multiplatform-settings, Room and SQLDelight for each storage need
The Multiplatform Data Stack
The data layer of a KMP app: one HttpClient with a per-platform engine, supabase-kt, DataStore through a path resolver, and how to choose a storage library.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
This lesson walks through the data layer of a KMP app: network calls with Ktor, Supabase calls with supabase-kt, and local storage with DataStore. For each part, you will see what is written once in commonMain, what has to differ per platform, and how the repo connects the two.
After this lesson, you can pick the right storage library for a KMP project, and avoid the most expensive mistake in a multiplatform data layer: silently losing existing users' data after an update.
Repository interfaces live in :domain and implementations live in :data. This lesson focuses on the parts of :data that touch the platform.
Part 1: The Overall Picture
| Component | commonMain | Android | iOS |
|---|---|---|---|
| HTTP | HttpClient in DataModule | The Android.create() engine | The Darwin.create() engine |
| Supabase | SupabaseClientFactory | Ktor engine picked from the dependencies | Ktor engine picked from the dependencies |
| Preferences | createPreferencesDataStore in DataStoreFiles.kt | AndroidDataStoreFileResolver | IosDataStoreFileResolver |
| Logging | Kermit | Kermit, plus Timber in Android-only code | Kermit |
The same pattern repeats on every row: logic and configuration live in commonMain, and the platform only supplies the smallest piece that can't be shared. The bindings for that piece live in dataPlatformModule, an expect val declared in :data's commonMain with actual versions for Android and iOS.
Part 2: One HttpClient, An Engine Per Platform
The Ktor client is multiplatform, but actually sending a request needs a separate engine for each platform. Open data/build.gradle.kts:
androidMain.dependencies {
implementation(libs.ktor.client.android)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
The Darwin engine uses Apple's networking system, and the Android engine uses Android's networking APIs. The engine table in the Ktor docs lists which engine runs on which platform. Some projects choose OkHttp for Android, or CIO for every platform.
The repo binds the engine in dataPlatformModule: single<HttpClientEngine> { Android.create() } on Android and single<HttpClientEngine> { Darwin.create() } on iOS. The HttpClient itself is created once in DataModule in commonMain; it receives the engine through get<HttpClientEngine>() and installs plugins:
| Plugin | Configuration | Why |
|---|---|---|
ContentNegotiation | JSON with the shared Json instance | One ignoreUnknownKeys configuration for every request |
HttpTimeout | 10 seconds per request, 5 seconds to connect | A slow network doesn't hang the screen forever |
Logging | BODY level, only when isDebugBuild() | Body logs help when debugging, but bodies can contain sensitive data, so release builds don't log them |
Because the engine is a binding, tests can swap in MockEngine from ktor-client-mock. GeminiHintProviderTest (in androidHostTest, because it also uses MockK) does this to simulate Gemini responses, network errors and retry counts without a real network.
If you call HttpClient() without passing an engine, Ktor picks one from the dependencies in the build. That's compact, but it hides the decision. The repo passes the engine explicitly so tests can replace it and so you can tell which engine is running just by reading the code.
Docs: Ktor client engines.
Part 3: supabase-kt
Open SupabaseClientFactory. The factory reads the URL and the publishable key (StaticSecretKey.SUPABASE_PUBLISHABLE_KEY) from SecretManager, then calls createSupabaseClient with two plugins, Auth and Postgrest.
The point to notice: the factory doesn't pass in DataModule's HttpClient. supabase-kt creates its own Ktor client, and the Supabase docs require adding a Ktor engine for each target. The repo meets that requirement with the same two engine dependencies from Part 2. The consequence: the timeout and logging configured in DataModule don't apply to Supabase requests. When you debug a Supabase request, don't look for KtorHttpClient logs.
supabase-kt still lets you pass an engine through the httpEngine parameter. The LeaderboardRepositoryImplQueryTest test in commonTest passes a MockEngine there to check that the query sent to PostgREST is ordered by rank and uses the right row limit. The test lives in commonTest, so it runs on both the JVM and the iOS Simulator.
On security: Supabase's publishable key (sb_publishable_...) is designed to live in the app, and data safety relies on Row Level Security on the server. The tables' RLS policies live in scripts/sql/setup.sql.
Docs: supabase-kt installing.
Part 4: Multiplatform DataStore
On plain Android you're used to writing by preferencesDataStore("name"). That delegate is Android-only and hides the file path. The multiplatform version makes you say exactly where the file lives.
The repo splits this into three pieces in DataStoreFiles.kt:
fun interface DataStoreFileResolverwith a singlepathFor(name)function.object DataStoreNames, which keeps the four store names in one place, so a typo in a name can't orphan a file.createPreferencesDataStore(resolver, name), which callsPreferenceDataStoreFactory.createWithPath.
The two implementations:
| Platform | Class | Where the file lives |
|---|---|---|
| Android | AndroidDataStoreFileResolver | The exact file the old delegate used, obtained through androidx's preferencesDataStoreFile(name) |
| iOS | IosDataStoreFileResolver | A datastore folder in Application Support, located with NSFileManager |
Why The Path Matters
The KDoc of DataStoreFileResolver says it plainly: a wrong path doesn't make the build or the tests fail. The app opens an empty file, and every existing user loses their settings, play history and achievements after the update. This is the one change in the migration that can't be undone.
That's why the Android version doesn't assemble filesDir/datastore/... by hand but calls the androidx helper. If androidx changes where it puts the file, the resolver follows. DataStoreFileResolverTest pins the path so any change shows up:
./gradlew :data:testAndroidHostTest --tests "*DataStoreFileResolverTest"
This command was run to verify it (6 tests green).
On iOS, the example in the Android docs puts the file in the Documents folder. The repo chooses Application Support, in a separate datastore folder. The KDoc of IosDataStoreFileResolver explains: the Documents folder can show up in the Files app, and these stores aren't user documents. The repo doesn't mark them as excluded from backup, so the files are still included in the default iCloud backup. They survive app updates and are removed when the app is deleted, like filesDir on Android. When you read an example in the docs, treat the path as a decision you have to make.
One Instance Per File
In DataModule, each store is a single with a qualifier named after the store. A comment explains: the old delegate kept one instance per Context on its own, the factory doesn't, and two DataStores opening the same file fail on the first read. The DataStore docs state the same rule: never create more than one DataStore instance for the same file in the same process.
According to Android's KMP guide, DataStore supports KMP from version 1.1.0, and only Preferences DataStore is supported in KMP projects. The repo uses 1.2.1.
Docs: DataStore (Kotlin Multiplatform), DataStore.
Part 5: Choosing A Storage Library
DataStore isn't the only option. When you take over other projects, you will meet these libraries:
| Library | A good fit when | Notes |
|---|---|---|
| DataStore Preferences | Key-value data, asynchronous reads and writes through Flow | The repo uses it for settings, play history, achievements and the daily challenge |
| multiplatform-settings | Simple key-value data, synchronous reads and writes | Wraps SharedPreferences on Android and NSUserDefaults on iOS, with KeychainSettings for the Keychain |
| Room (KMP) | Relational data, complex queries, a team that already knows Room on Android | Uses KSP; the docs recommend BundledSQLiteDriver so every platform gets the same SQLite version |
| SQLDelight | You want to write SQL first and generate type-safe Kotlin from it | Has a driver for each platform |
This repo doesn't use multiplatform-settings. It used to be declared in data/build.gradle.kts and app/build.gradle.kts without a single Kotlin file importing com.russhwolf, and it was removed in PR #30. The leftover -dontwarn com.russhwolf.settings.** rule in app/proguard-rules.pro was removed in PR #32. That's a lesson for reading unfamiliar projects: a dependency in a build file only tells you what could be used. To know what is being used, search the code for imports.
Sensitive data such as tokens shouldn't live in an ordinary preferences file. On iOS, the right place is the Keychain.
Docs: Room (Kotlin Multiplatform), SQLDelight, multiplatform-settings.
Troubleshooting Table
| Symptom | Usual cause | Fix |
|---|---|---|
| Users lose their settings after an update, with no error | The DataStore path changed from the previous version | Compare the path with the old version, run DataStoreFileResolverTest |
| DataStore fails on the first read | Two instances opened the same file | Bind each store as a single with a qualifier, as DataModule does |
| iOS requests fail while Android works | The Darwin engine is missing from iosMain.dependencies | Add ktor-client-darwin to iosMain |
| No logs for Supabase requests | supabase-kt uses its own Ktor client | Debug at the repository layer, or use supabase-kt's logging |
| Request logs print bodies containing tokens in release builds | Logging installed unconditionally | Only install Logging in debug, as DataModule does |
Checkpoint
You are done with this lesson when you can:
- point to where
HttpClientis created and where each platform's engine is bound, - explain why the timeout in
DataModuledoesn't apply to Supabase requests, - run
DataStoreFileResolverTestand explain why the Android version callspreferencesDataStoreFileinstead of building the path itself, - say why each DataStore must be a
single, - choose a storage library for three needs: a "has seen onboarding" flag, a downloaded question list filtered by topic, a refresh token.