DEV Community

Tom Horvat
Tom Horvat

Posted on • Originally published at underdroid.hashnode.dev

Lean Caching - A Custom NetworkBoundResource in Kotlin

Orchestrating data between a remote API and a local database is a foundational problem in mobile development. While the industry consensus rightly leans toward the Single Source of Truth pattern, the implementation often degrades into massive, fragile repository classes littered with boilerplate try-catch blocks and manual thread switching.

Solving this does not require a heavy, third-party state-management library. A clean, generic networkBoundResource built with Kotlin Flows and Coroutines is highly effective, provided you leverage Kotlin's advanced language features correctly.

Let me explain by demonstrating this pattern in my RandomPokemon project. By focusing on reified generics, inline functions, and structured concurrency, I’ve created a highly fault-tolerant data layer that I drop into my projects to handle caching without the bloat.

State Representation: Rethinking ResultState

Orchestrating complex data flows requires a strict contract for state representation. A standard sealed class handles the basic loading, success, and error states, but the mechanism used to unwrap that state dictates the cleanliness of the presentation layer. The versatility of the ResultState lends itself well to be used in API call returns, I/O operations, or for use cases being observed by the ViewModel.

sealed class ResultState {
    data class Complete<out O>(val data: O?) : ResultState()
    data class Running(val message: String? = null) : ResultState()
    data class Error(val error: Throwable? = null) : ResultState()

    inline fun <reified O> parse(
        onRunning: (String?) -> Unit = {},
        onError: (Throwable?) -> Unit = {},
        onComplete: (O?) -> Unit = {},
    ) {
        when (this) {
            is Running -> onRunning(this.message)
            is Error -> onError(this.error)
            is Complete<*> -> this.data?.let {
                it.takeIf { it is O }?.let { result -> onComplete(result as O?) }
                    ?: run { onError(IllegalArgumentException("Expected output of type ${O::class.simpleName}; got ${data?.let { data::class.simpleName }}")) }
            } ?: run { onComplete(null) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Standard state implementations often force the caller to write repetitive when statements and perform unsafe casts in the UI or ViewModel. I intentionally avoid this by relying on the inline fun parse function to safely handle type checking and casting at runtime. If the type does not match, it gracefully routes to the onError block, preventing an abrupt ClassCastException and keeping the app's crash-free rate intact.

The Orchestrator: networkBoundResource

My core orchestrator is a single, highly optimized function. Relying heavily on inline and crossinline, it eliminates the runtime overhead associated with the higher-order functions passed into it.

inline fun <reified DomainType, reified DtoType> networkBoundResource(
    crossinline query: () -> Flow<ResultState>,
    crossinline fetch: suspend () -> ResultState,
    crossinline saveFetchResult: suspend (DtoType) -> Unit,
    crossinline shouldFetch: (DomainType?) -> Boolean = { it == null },
    crossinline mapDtoToDomain: (DtoType) -> DomainType,
    crossinline onCachePresent: () -> Unit = {}
) = flow {
    emit(ResultState.Running("Checking cache..."))
    val cachedDataResult = query().last()

    var cachedData: DomainType? = null
    cachedDataResult.parse<DomainType>(
        onError = { Timber.w(it, "Cache returned an error.") }
    ) { cachedData = it }

    if (shouldFetch(cachedData)) {
        emit(ResultState.Running("Cache miss. Fetching from remote..."))

        when (val remoteResult = fetch()) {
            is ResultState.Complete<*> -> {
                val dto = remoteResult.data as? DtoType

                if (dto != null) {
                    emit(ResultState.Complete(mapDtoToDomain(dto)))

                    supervisorScope {
                        launch {
                            try {
                                saveFetchResult(dto)
                            } catch (e: Exception) {
                                Timber.e(e, "Failed to save network resource to cache.")
                            }
                        }
                    }
                } else {
                    emit(ResultState.Error(IllegalStateException("Type mismatch: Remote fetch did not return the expected DTO.")))
                }
            }
            is ResultState.Error -> emit(remoteResult)
            is ResultState.Running -> emit(remoteResult)
        }
    } else {
        onCachePresent()
        emit(ResultState.Complete(cachedData!!))
    }
}
Enter fullscreen mode Exit fullscreen mode

Two specific architectural decisions drive this implementation, focusing on performance and fault tolerance:

  1. Immediate UI Emissions: When the remote fetch succeeds, the code emits the Complete state before initiating the database save. Disk I/O is slow. Forcing the UI to wait for Room to finish inserting records is an unnecessary bottleneck. The user gets the mapped domain model instantly.

  2. Detached Caching: Launching saveFetchResult inside a supervisorScope acts as a defensive shield. If the database insertion fails due to a constraint violation or disk issue, the exception is caught and logged by Timber. The overarching coroutine running the Flow does not crash, and the UI retains the successfully fetched in-memory data.

Implementation: Concurrent Caching

An abstraction is only as effective as its call site. Integrating this into my PokemonRepoImpl demonstrates how I handle complex, relational data saving without blocking the thread.

class PokemonRepoImpl(
    private val remote: PokemonRepo.Remote,
    private val local: PokemonRepo.Local,
    private val dispatchers: DispatcherProvider
) : PokemonRepo {
    override fun getPokemonById(id: Long) = networkBoundResource<PokemonDetails, PokemonDto>(
        query = { local.getPokemonById(id) },
        fetch = { remote.getPokemonById(id).last() },
        saveFetchResult = { dto -> cacheRemotePokemon(dto.toDomainModel()) },
        mapDtoToDomain = { dto -> dto.toDomainModel() }
    )

    private suspend fun cacheRemotePokemon(details: PokemonDetails) {
        supervisorScope {
            val saveOperations: List<Pair<String, suspend () -> Unit>> = listOf(
                "pokemon" to { local.savePokemon(details.pokemon) },
                "cries" to { local.saveCries(details.cries) },
                "sprites" to { local.saveSprites(details.sprites) },
                "abilities" to { local.saveAbilities(details.abilities) }
            )

            saveOperations.forEach { (name, action) ->
                launch(dispatchers.io) {
                    try {
                        action()
                    } catch (e: Exception) {
                        Timber.e(e, "Error caching $name")
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Mapping a large API payload to a relational database requires splitting the data into multiple tables. Executing these inserts sequentially scales poorly.

By wrapping the inserts in a supervisorScope and iterating through a list of saveOperations with launch(dispatchers.io), this repository performs concurrent database inserts. More importantly, the supervisorScope ensures fault isolation. If the API returns a malformed URL for a sprite that violates a database constraint, that specific coroutine fails and logs an error, but the parent scope remains active, allowing the pokemon, cries, and abilities to be cached successfully.

Final Thoughts

This implementation is my take on a pragmatic approach to Android architecture. Combining reified generics for type safety, inline functions for zero-overhead abstractions, and structured concurrency for fault-tolerant caching - yields a repository layer that is highly readable and extremely resilient. It solves a universal problem by leaning on the language, entirely avoiding the need for bloated external dependencies.

Top comments (0)