# 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.

- URL: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-04-kmp-data-stack
- Course: [Mobile Action Pro](https://dantech.academy/en/courses/kotlin-accelerator.md)
- Module: Kotlin Multiplatform Foundations
- Duration: 15 min
- Difficulty: beginner
- Access: Free, full lesson
- Tiếng Việt: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-04-kmp-data-stack.md

## Objectives

- 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

---

## 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`:

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

| 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](https://ktor.io/docs/client-engines.html).

## 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](https://supabase.com/docs/reference/kotlin/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:

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

```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)](https://developer.android.com/kotlin/multiplatform/datastore), [DataStore](https://developer.android.com/topic/libraries/architecture/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)](https://developer.android.com/kotlin/multiplatform/room), [SQLDelight](https://sqldelight.github.io/sqldelight/latest/), [multiplatform-settings](https://github.com/russhwolf/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 `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.

---

- Previous lesson: [iOS Host And Swift Interop](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-shared-kmp-foundations/lesson-03-ios-host-swift-interop.md)
- Next lesson: [Design An Immutable UI Model](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/02-compose-ui-from-state/lesson-01-design-immutable-ui-models.md)
