Key-Value Storage on Steroids

Tired of writing save/read/reset methods for every key-value store in your repository, just like in this interface?

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

Or maybe you need something similar, but in different repositories:

interface LocalUserRepository {
    fun getLocalUserInfo(): User?
}
interface UserUpdateRepository {
    fun updateUserInfo(user: User)
}

And you can't share these interfaces between modules because your team lead said so?

KStorage

Why create a boilerplate when you can use the library path Storage and transmit Krate anywhere, as in this example:

// Представим что у нас есть ViewModel, которая отвечает за редактирование пользовательской информации локально
class ViewModel(userKrateProvider: () -> MutableKrate<T>) {
    // Создаем свой krate
    private val userKrate = userKrateProvider.invoke()

    // Грузим последнее значение
    private val _userStateFlow = MutableStateFlow(userKrate.cachedValue)
    val userStateFlow = _userStateFlow.asStateFlow()

    // Обновляем значение
    fun onUserSave(user: User) {
        userKrate.save(user)
        _userStateFlow.update { userKrate.cachedValue }
    }

    // Полностью очищаем krate
    fun onUserDeleted() {
        userKrate.reset()
        _userStateFlow.update { userKrate.cachedValue }
    }

    // Представим, что здесь еще несколько функций
    // Одна из функций меняет имя пользователя
    fun updateSomeUserProperty(name: String) {
        _userStateFlow.update { it.copy(name = name) }
    }
}

“But in a clean architecture you should use a use case that uses a repository that uses…” –
Firstly, Krate, MutableKrate — these are already interfaces that can be mocked. Secondly, this is just an example. You can
use Krateas you wish.

In any case, this example shows that sometimes you don't need use cases or repositories for Krate. Because
they have already separated your logic of working with the data layer. You do not see how they are loaded or saved. You get a ready-made
result. It can even be mapped.

For example, Krate can make yours smile UserModel V ServerUserModel inside. But at the presentation level you will only use UserModel. You will have no idea what is there ServerUserModel.

Mapping

Disclaimer: this example is far-fetched!

Imagine that the server sends us user data as strings. And we need to cache the model on the device.

// Наша мапнутая модель для Presentation слоя
data class UserModel(val name: String, val age: Int, val salary: Int)
// Модель для data-слоя
class ServerUserModel(val name: String, val age: String, val salary: String)

// Создаем Krate для ServerUserModel
// Представьте, что settings здесь — это обычное хранилище ключ-значение.
// Это может быть SharedPreferences или что-то другое
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
    }
)

// Теперь нам нужно сопоставить его с 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)
    }
)

You might say it's a bit redundant, and yes, it is indeed a bit redundant in one place to use it throughout the project, instead of redundantly using it in a bunch of other places, in every repository/usecase.

In fact, it would be more convenient to use something like the Mapper extension:

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

Standard key-value storage

The example above is clearly far-fetched. It is unlikely that you will map your key-value storage model to server models and vice versa.

A more common use is as a regular key-value store.

With SharedPreferences you can read properties by key. With kstorage it's actually the same thing, in terms of guts, but of course you end up with a full class model:

enum class ThemeEnum { DARK, LIGHT, }
// Тема это у нас Enum, а не просто число
// А lastLaunchTime так вообще java Instant
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 a boilerplate. Moreover, the keys for this key-value store are only in one place (don't forget to move them to constants).

Nullability

Nullable is a great thing, but do you really need to create multiple krates For nullable And null-safe models? Of course not. There is an extension withDefault { TODO() }which will help.

In the example below, we'll use the decorator pattern. We'll have a MutableKrate above another MutableKrate. And set the default value to 10.

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

Suspend crates

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

You can use DataStore with suspend krates. There are SuspendMutableKrate, FlowKrate and others for Flow key-value stores. All of them support caching, by the way.

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
        }
    }
)
// Инициализируем значение krate с дефолтным значением
val intKrate = DataStoreFlowMutableKrate<Int?>(
    key = intPreferencesKey("some_int_key"),
    dataStore = dataStore,
    factory = { null }
).withDefault(12)

Conclusion

Personally, I really like this approach. It has helped me reduce the boilerplate in my projects, but I still see a lot of room for improvement in the code and the library as a whole.

I hope you will notice the positive aspects of this approach, suggest how to improve it, or point out the shortcomings. Any feedback is welcome. Thanks for reading.

There is also a similar library called KStorewhich can be useful in such cases.

Links

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *