Key-Value Storage on Steroids

Roman
4 min readAug 17, 2024

--

Tired of writing save/read/reset for every key-value storage item in your repository like that?

interface SplashRepository {
fun isFirstLaunch(): Boolean
fun setFirstLaunch(value: Boolean)
fun resetFirstLaunch()
}

Or may be somewhere you need something like that:

interface LocalUserRepository {
fun getLocalUserInfo(): User?
}

And somewhere you need this?

interface UserUpdateRepository {
fun updateUserInfo(user: User)
}

And you can’t share this repositories between modules because your team lead told you so?

KStorage

Why repeat yourself when you can just use [Kstorage’s way](https://github.com/makeevrserg/klibs.kstorage) and pass it whenever you like as in this example:

class ViewModel(userKrateProvider: () -> MutableKrate<T>) {
// Create you custom crate
private val userKrate = userKrateProvider.invoke()

// Remember krate's last value
private val _userStateFlow = MutableStateFlow(userKrate.cachedValue)
val userStateFlow = _userStateFlow.asStateFlow()

// Update value
fun onUserSave(user: User) {
userKrate.save(user)
_userStateFlow.update { userKrate.cachedValue }
}

// Clear krate entirely
fun onUserDeleted() {
userKrate.reset()
_userStateFlow.update { userKrate.cachedValue }
}

// Imagine there's more functions like that
// But here example of name change
fun updateSomeUserProperty(name: String) {
_userStateFlow.update { it.copy(name = name) }
}
}

“But actually, in clean architecture, you should use use case which is using repository which is using….” — Firstly, the `Krate`, `MutableKrate` is already interfaces which can be mocked. Secondly, it’s just an example. You can use Krates as you wish.

Anyway, this example shows that sometimes you won’t need use cases or repository for Krates. Because it’s already separated your data logic. You don’t see how it’s loaded or saved. You’re getting clear result. It even can be mapped.

For example, the krate can map your `UserModel` into `ServerUserModel` inside. But on presentation layer you’re using just your default `UserModel`.

Mapping

**Discalimer**: this example is far-fetched!

Let’s see this on the example. Imagine, the server is sending us user data with string-only format. And we should cache model on disk.

data class UserModel(val name: String, val age: Int, val salary: Int)
class ServerUserModel(val name: String, val age: String, val salary: String)

// Let's create Krate for ServerUserModel
// Imagine settings here just like any key-value storage.
// It can be SharedPreferences or anything else
internal class ServerUserModelKrate(
settings: Settings,
key: String,
) : MutableKrate<ServerUserModel?> by DefaultMutableKrate(
factory = { null },
loader = {
runCatching {
ServerUserModel(
name = settings.requireString("${key}_NAME"),
age = settings.requireString("${key}_AGE"),
salary = settings.requireString("${key}_SALARY"),
)
}.getOrElse { ServerUserModel("", "", "") }
},
saver = { serverUserModel ->
settings["${key}_NAME"] = serverUserModel.name
settings["${key}_AGE"] = serverUserModel.age
settings["${key}_SALARY"] = serverUserModel.salary
}
)

// Now we need to map it from ServerUserModel to UserModel
class UserModelKrate(
serverUserModelKrate: MutableKrate<ServerUserModel>
) : MutableKrate<UserModel?> by DefaultMutableKrate(
factory = { null },
loader = {
val serverModel = serverUserModelKrate.loadAndGet()
UserModel(
name = serverModel.name,
age = serverModel.age.toIntOrNull() ?: 0,
salary = serverModel.salary.toIntOrNull().orEmpty() ?: 0
)
},
saver = { userModel ->
val serverModel = ServerUserModel(
name = userModel.name.toString(),
age = userModel.age.toString(),
salary = userModel.salary.toString()
)
serverUserModelKrate.save(serverModel)
}
)

A little bit overhead you would say and yes, a little bit overhead in just one place to use it in whole project instead of map it in every repository/usecase.

There will be actually more convenient to use something like Mapper extension, but there’s currently no such thing yet:

fun <T, K> Krate<T>.map(to: (T) -> K, from: (K) -> T) = TODO()

Just key-value storage

The example above was clearly far-fetched. Very unlikely you’re mapping your key-value storage model from server models and vice versa.

More common usage is just for default key-value storage.\

With shared preferences, you are able to read properties key by key. With kstorage it’s actually the same if we’re talking about internal usage, but of course, in result you’re getting the class model:

enum class ThemeEnum { DARK, LIGHT, }
data class Settings(
val theme: ThemeEnum,
val isFirstLaunch: Boolean,
val lastLaunchTime: Instant
)
class SettingsKrate(
settings: Settings,
key: String,
) : MutableKrate<Settings?> by DefaultMutableKrate(
factory = { null },
loader = {
Settings(
theme = let {
val ordinal = settings.getInt("${key}_theme", 0)
ThemeEnum.entries[ordinal]
},
lastLaunchTime = let {
val epochSeconds = settings.getLong("${key}_last_launch_time", Instant.now().epochSecond)
Instant.ofEpochSecond(epochSeconds)
},
isFirstLaunch = settings.getBoolean("${key}_is_first_launch", true),
)
},
saver = { settingsModel: Settings ->
settings.put("${key}_theme", settingsModel.theme.ordinal)
settings.put("${key}_last_launch_time", settingsModel.lastLaunchTime.epochSecond)
settings.put("${key}_is_first_launch", settingsModel.isFirstLaunch)
}
)

Great! Now you can use your `SettingsModel` anywhere without more boilerplate. Furthermore, the keys for this key-value item is placed only in one place(don’t forget to move it into const)

Nullability

The nullability is great thing, but do we need to create multiple krates for nullable and null-safe models?
Of course not, there’s extension `withDefault { TODO() }` which can help.

In example below we will decorate default MutableKrate with another MutableKrate and set 10 as default value.

val nullableKrate: MutableKrate<Int?> = TODO()
val nullSafeKrate = nullableKrate.withDefault { 10 }

Suspend krates

“Wait, I’m using DataStore. How can I implement krates if the examples above was only for non-suspend functions?”

You can use datastore with suspend krates. There’s SuspendMutableKrate, FlowKrate etc for flow-based key-value storages:

internal class DataStoreFlowMutableKrate<T>(
key: Preferences.Key<T>,
dataStore: DataStore<Preferences>,
factory: ValueFactory<T>,
) : FlowMutableKrate<T> by DefaultFlowMutableKrate(
factory = factory,
loader = { dataStore.data.map { it[key] } },
saver = { value ->
dataStore.edit { preferences ->
if (value == null) preferences.remove(key)
else preferences[key] = value
}
}
)
// Initialize krate value
val intKrate = DataStoreFlowMutableKrate<Int?>(
key = intPreferencesKey("some_int_key"),
dataStore = dataStore,
factory = { null }
).withDefault(12)

Conclusion

Personally, I very like this approach. It helped me to reduce boilerplate in my projects, but I still see there’s a lot to improve in codebase and overall.

Hope, you will tell the good sides of this approach, will make a suggestion of how to improve or tell the downsides. Any feedback is very welcome. Thanks for reading.

There’s also something-like library name [KStore](https://github.com/xxfast/KStore) which also can be useful in such cases.

Links

- [KStorage](https://github.com/makeevrserg/klibs.kstorage)
- [KStore](https://github.com/xxfast/KStore)
- [Sample](https://github.com/makeevrserg/Koleso/blob/master/modules/services/db-api/src/commonMain/kotlin/com/makeevrserg/koleso/service/db/api/SettingsParticipantsApi.kt) -
The sample with mini-project, which is using kstorage to handle key-value storage database.

--

--