Sitemap

KStorage — a Unified Approach to Key-Value Storage in Kotlin Multiplatform

--

Key-Value storages are very convenient… until you want something more.

SharedPreferences on Android, DataStore, NSUserDefaults on iOS, Multiplatform Settings, local files, or even SQL — each of these options requires writing specific code. Each API needs additional wrappers and repositories to increase abstraction levels and facilitate painless replacement of specific libraries.

KStorage is the ultimate wrapper that solves this problem. The library allows you to create wrappers for Key-Value storages like DataStore. You can also create a Key-Value storage where the key is a filename, and the value is its serializable content. Moreover, you can even wrap an SQL query — for example, to create, update, or delete a user.

Under the Hood

At the core is the concept of “Boxes” — Krates. Wrappers around a value that can be saved and loaded. The simplest way to get started is to use DefaultMutableKrate, specifying how to store and read the value:

private val intSettingsMap: MutableMap<String, Int>
val mutableKrate: MutableKrate<Int> = DefaultMutableKrate(
factory = { 0 },
loader = { intSettingsMap["INT_KEY"] },
saver = { value -> intSettingsMap["INT_KEY"] = value }
)

Why Do You Need It?

  1. Unified Access. Regardless of the platform or Key-Value library, you’ll always work with the same API.
  2. Testability. No need to drag real dependencies into unit tests. Everything can be mocked.
  3. Extensibility. The library is built on extension functions, making it very extensible. Wrap anything in a Krate — from Map<T,V> to SQL queries!
  4. Type Safety. The API provides compile-time type checks, reducing the chance of errors.
  5. Lightweight. The library is extremely lightweight, depending only on the language and first-party coroutines library.

Extensibility and Features

But is the library really that extensible? Let’s take a look at the existing extensions.

As seen above, we have DefaultMutableKrate<T>. That’s great — but what if we want to cache the value?

It’s simple. Enter the beloved decorators!

val cachedMutableKrate: CachedKrate<Int> = mutableKrate.asCachedMutableKrate()

CachedKrate<T> is a decorator over a regular Krate<T>. It includes an additional value field that is loaded during initialization.

What if you want to use StateFlow as the cached value? Same principle. Create a decorator and apply the same logic.

val stateFlowKrate: StateFlowMutableKrate<Int> = mutableKrate.asStateFlowMutableKrate()

That’s it. Now we can subscribe to value changes.

Race Condition

Caution is needed. If the library isn’t reactive, it’s impossible to synchronize two Krates. However, this should be handled by the developer, since KStorage provides just a wrapper. That said, if you have a suggestion for solving this issue — I’d love to hear it.

Nullability

Sometimes we need a nullable Krate, and sometimes not. Here’s how to solve that:

val nullableMutableKrate: MutableKrate<Int> = DefaultMutableKrate(
factory = { null },
loader = { intSettingsMap["INT_KEY"] },
saver = { value ->
if (value == null) intSettingsMap.remove("INT_KEY")
else intSettingsMap["INT_KEY"] = value
}
)
val nonNullableMutableKrate = nullableMutableKrate.withDefault { 102 }

Once again, decorators and extension functions save the day. Now, if loading the value fails — 102 is returned by default.

Dynamic Keys

Dynamic keys are fully supported.

fun userKrate(userId: Int): MutableKrate<User?> = DefaultMutableKrate(
factory = { 0 },
loader = {
runCatching { File("${userid}.json").toUser() }
.getOrNull()
},
saver = { user ->
if (value == null) File("${userid}.json").delete()
else File("${userid}.json").also(File::createNewFile).writeText(user.toJson())
}
)

Here, we read user info from a file. Similarly, SQL queries can be supported.

Suspend and Flo

To support caching, you’ll need to pass a CoroutineContext on which the first value will be loaded. By default, the factory value is used.

val nonNullableStateFlowSuspendMutableKrate = DefaultStateFlowSuspendMutableKrate(
factory = { null },
loader = { intSettingsMap["INT_KEY"] },
saver = { value ->
if (value == null) intSettingsMap.remove("INT_KEY")
else intSettingsMap["INT_KEY"] = value
},
coroutineContext = Dispatchers.IO
)

As you can see, the API is identical to the previous one, except functions here are suspend.

And of course, the extensions are similar as well.

Flow

Flow is supported. If you’re using a single DataStore, multiple Krates will be synchronized with each other.

private val dataStore: DataStore<Preferences> = TODO()
val flowKrate = DefaultFlowMutableKrate(
factory = { 10 },
loader = { dataStore.data.map { it[key] } },
saver = { value ->
dataStore.edit { preferences ->
preferences[key] = value
}
}
)

In this case, loader takes a Flow instead of a single-shot load method.

State? Flow?

But this is just Flow. What if I want StateFlow? What do I do? No worries — that’s already covered:

val stateFlowValue = flowKrate.stateFlow(GlobalScope)

Extensibility Revisited

Let’s stress test the extensibility. Suppose we want to transform one Krate into another. We’ll need some kind of mapping. Let’s create one.

fun <T, K> CachedKrate<T>.map(to: (T) -> K): CachedKrate<K> {
return object : CachedKrate<K> {
private var _cachedValue = to.invoke(this@map.cachedValue)
override val cachedValue: K
get() = _cachedValue
override fun getValue(): K {
return to.invoke(this@map.cachedValue)
}
}
}
class Repository(intSettingsMap: MutableMap<String, Int>) {
val cachedIntKrate: CachedKrate<Int> = DefaultMutableKrate(
factory = { 0 },
loader = { intSettingsMap["INT_KEY"] },
saver = { value -> intSettingsMap["INT_KEY"] = value }
).asCachedKrate()
val cachedStringKrate: CachedKrate<String> = cachedIntKrate.map(to = { int -> "${int}_value" })
}

As we can see, using anonymous functions we successfully created a mapping function for cached Krates.

Conclusion

If you’re looking for a simple and efficient solution for data storage in Kotlin, take a look at KStorage. Type-safe API, multiplatform support, ease of use — all make this library an excellent choice for your project.

Pros:

  • Unified approach to data storage
  • Easy integration with any library
  • Rich set of extension functions
  • High level of abstraction for building custom extensions and decorators
  • No unnecessary dependencies

Cons

  • Race conditions — be careful when using non-reactive Key-Value stores
  • Adaptation — you’ll need to write lots of wrappers

Where to Find?

For a more detailed tutorial and installation guide, see:

--

--