Computer scienceProgramming languagesKotlinKotlin Multiplatform (KMP)Persistence & storage

Key-value storage (Multiplatform Settings)

3 seconds read

Not everything worth persisting belongs in a table. An auth token, a "has seen onboarding" flag, a theme preference — each is a single, independent value, and reaching for a database to store one boolean is more machinery than the problem needs. multiplatform-settings fills that gap: a KMP library that wraps each platform's native key-value store — SharedPreferences on Android, NSUserDefaults on iOS, java.util.prefs on the JVM, and more — behind one shared Settings interface.

The Settings interface

Settings exposes typed put/get pairs, with defaults and nullable variants, plus basic housekeeping:

interface Settings {
    fun putString(key: String, value: String)
    fun getString(key: String, defaultValue: String = ""): String
    fun getStringOrNull(key: String): String?
    // the same shape repeats for Int, Long, Float, Double, and Boolean

    fun hasKey(key: String): Boolean
    fun remove(key: String)
    fun clear()
}

Using it looks like this, wherever a Settings instance is available:

settings.putBoolean("has_seen_onboarding", true)
val hasSeenOnboarding = settings.getBoolean("has_seen_onboarding", false)

Anything relational — anything with more than one related field per record, or that needs querying — still belongs in the local database covered earlier. This is for the small, standalone values that don't.

Getting a Settings instance per platform

Each platform wraps its own native store behind the same interface, using a class that takes the platform's own storage object as a constructor parameter:

// androidMain
val settings: ObservableSettings = SharedPreferencesSettings(
    context.getSharedPreferences("app_settings", Context.MODE_PRIVATE)
)

// iosMain
val settings: ObservableSettings = NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)

Both return ObservableSettings rather than plain Settings — both platform implementations already support observing changes with no extra wrapping, which matters in a moment.

Wiring this in follows the same expect/actual-plus-DI shape already used for the local database driver:

// commonMain
expect class SettingsFactory {
    fun create(): ObservableSettings
}

// androidMain
actual class SettingsFactory(private val context: Context) {
    actual fun create(): ObservableSettings =
        SharedPreferencesSettings(context.getSharedPreferences("app_settings", Context.MODE_PRIVATE))
}

// iosMain
actual class SettingsFactory {
    actual fun create(): ObservableSettings =
        NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
}
// androidMain
actual val platformModule: Module = module {
    single { SettingsFactory(androidContext()) }
}

// iosMain
actual val platformModule: Module = module {
    single { SettingsFactory() }
}

// commonMain
fun appModule() = module {
    includes(platformModule)
    single<ObservableSettings> { get<SettingsFactory>().create() }
}

The library's own documentation recommends exactly this — wiring the platform delegate through whatever DI setup a project already has, with expect/actual alone as a fallback only for projects without one. A separate multiplatform-settings-no-arg artifact also exists if you'd rather skip writing this factory yourself; it ships a ready-made one that obtains the Android context automatically. Either way, there's no separate driver artifact to add per platform the way the local database needed — SharedPreferencesSettings and NSUserDefaultsSettings both ship inside the one core dependency, and each target simply picks up its own.

Observing changes as a Flow

The multiplatform-settings-coroutines module adds Flow-returning extensions on ObservableSettings:

val themeFlow: Flow<String> = settings.getStringFlow("theme", "system")

That's the same kind of Flow already covered for observing changing values in Compose — collecting it drives recomposition the same way:

@Composable
fun ThemeAwareRoot() {
    val theme by themeFlow.collectAsState(initial = "system")
    // ...
}

Whenever the stored value changes — on any platform — the Flow emits and the UI recomposes, with no polling and no manual refresh logic. The only difference from other Flow-driven state already covered is where the values are coming from: persisted storage instead of in-memory business logic.

Gradle setup

commonMain.dependencies {
    implementation("com.russhwolf:multiplatform-settings:1.3.0")
    implementation("com.russhwolf:multiplatform-settings-coroutines:1.3.0")
}

A multiplatform-settings-test artifact is also available, providing MapSettings — an in-memory Settings implementation meant specifically for tests, so a fake doesn't need to be written by hand for commonTest the way one might for a custom interface. A multiplatform-settings-datastore variant also exists, backed by Jetpack DataStore instead of SharedPreferences/NSUserDefaults directly, for projects that have already standardized on DataStore — not covered further here, but worth knowing it's there.

Conclusion

multiplatform-settings gives shared code one interface for small, independent persisted values, wrapping each platform's native key-value store behind the same expect/actual-plus-DI pattern already used elsewhere — with one addition worth remembering: ObservableSettings turns any stored value into a Flow, so persisted state can drive Compose recomposition exactly the way in-memory state already does.

How did you like the theory?
Report a typo