Dan Tech Academy
After this lesson you will
  • 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
Duration15 min
DifficultyBeginner

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.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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

ComponentcommonMainAndroidiOS
HTTPHttpClient in DataModuleThe Android.create() engineThe Darwin.create() engine
SupabaseSupabaseClientFactoryKtor engine picked from the dependenciesKtor engine picked from the dependencies
PreferencescreatePreferencesDataStore in DataStoreFiles.ktAndroidDataStoreFileResolverIosDataStoreFileResolver
LoggingKermitKermit, plus Timber in Android-only codeKermit

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:

kotlin
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:

PluginConfigurationWhy
ContentNegotiationJSON with the shared Json instanceOne ignoreUnknownKeys configuration for every request
HttpTimeout10 seconds per request, 5 seconds to connectA slow network doesn't hang the screen forever
LoggingBODY 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 DataStoreFileResolver with a single pathFor(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 calls PreferenceDataStoreFactory.createWithPath.

The two implementations:

PlatformClassWhere the file lives
AndroidAndroidDataStoreFileResolverThe exact file the old delegate used, obtained through androidx's preferencesDataStoreFile(name)
iOSIosDataStoreFileResolverA 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:

bash
./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:

LibraryA good fit whenNotes
DataStore PreferencesKey-value data, asynchronous reads and writes through FlowThe repo uses it for settings, play history, achievements and the daily challenge
multiplatform-settingsSimple key-value data, synchronous reads and writesWraps 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 AndroidUses KSP; the docs recommend BundledSQLiteDriver so every platform gets the same SQLite version
SQLDelightYou want to write SQL first and generate type-safe Kotlin from itHas 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

SymptomUsual causeFix
Users lose their settings after an update, with no errorThe DataStore path changed from the previous versionCompare the path with the old version, run DataStoreFileResolverTest
DataStore fails on the first readTwo instances opened the same fileBind each store as a single with a qualifier, as DataModule does
iOS requests fail while Android worksThe Darwin engine is missing from iosMain.dependenciesAdd ktor-client-darwin to iosMain
No logs for Supabase requestssupabase-kt uses its own Ktor clientDebug at the repository layer, or use supabase-kt's logging
Request logs print bodies containing tokens in release buildsLogging installed unconditionallyOnly install Logging in debug, as DataModule does

Checkpoint

You are done with this lesson when you can:

  • point to where HttpClient is created and where each platform's engine is bound,
  • explain why the timeout in DataModule doesn't apply to Supabase requests,
  • run DataStoreFileResolverTest and explain why the Android version calls preferencesDataStoreFile instead 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.
Sign in