ryanw-mobile / OctoMeter

🔥Kotlin Multiplatform Desktop/Android/iOS Energy Tracker app
Other
78 stars 7 forks source link

Set up cached data source #240

Closed ryanw-mobile closed 1 week ago

ryanw-mobile commented 1 week ago

Currently we use simple variables within the repository to cache certain RestAPI values. This should violate the Clean Architecture, so it's better we properly make it as a data source.

In the context of Clean Architecture, the primary goal is to separate concerns and maintain clear boundaries between different layers of the application. The typical layers in Clean Architecture are:

  1. Presentation Layer: Contains UI components and view models (in the case of MVVM).
  2. Domain Layer: Contains business logic and use cases.
  3. Data Layer: Contains repositories and data sources.

Given your scenario:

Approaches to Caching in Clean Architecture

  1. Internal Caching in the Repository:

    • While having a private variable in the repository for caching can be convenient, it can blur the separation of concerns because the repository should ideally focus on mediating between the data sources and the domain layer, not managing the specifics of caching.
  2. Separate Cached Data Source:

    • A more aligned approach with Clean Architecture is to treat the cache as another data source. This way, the repository coordinates between multiple data sources (network and cache), maintaining a clear separation of concerns.

Implementation Details

Here’s how you can set up a clean architecture-aligned caching mechanism:

1. Define a Cache Data Source

interface CacheDataSource {
    fun getCachedData(key: String): DataType?
    fun cacheData(key: String, data: DataType)
    fun isCacheValid(key: String): Boolean
}

class InMemoryCacheDataSource : CacheDataSource {
    private val cache = mutableMapOf<String, Pair<DataType, Long>>()
    private val cacheValidityDuration = 24 * 60 * 60 * 1000 // 1 day in milliseconds

    override fun getCachedData(key: String): DataType? {
        return cache[key]?.takeIf { isCacheValid(key) }?.first
    }

    override fun cacheData(key: String, data: DataType) {
        cache[key] = data to System.currentTimeMillis()
    }

    override fun isCacheValid(key: String): Boolean {
        return cache[key]?.let { 
            System.currentTimeMillis() - it.second < cacheValidityDuration 
        } ?: false
    }
}

2. Update the Repository to Use the Cache Data Source

class Repository(
    private val remoteDataSource: RemoteDataSource,
    private val cacheDataSource: CacheDataSource
) {
    suspend fun getData(key: String): DataType {
        if (cacheDataSource.isCacheValid(key)) {
            return cacheDataSource.getCachedData(key) ?: fetchDataAndCache(key)
        }
        return fetchDataAndCache(key)
    }

    private suspend fun fetchDataAndCache(key: String): DataType {
        val data = remoteDataSource.fetchData(key)
        cacheDataSource.cacheData(key, data)
        return data
    }
}

Benefits of This Approach

  1. Separation of Concerns: The repository is responsible for orchestrating data flow between different sources, but it doesn't handle the specifics of caching.
  2. Testability: Both the cache and the repository can be tested independently. You can mock the CacheDataSource and RemoteDataSource in unit tests.
  3. Flexibility: If you decide to change your caching strategy (e.g., move from in-memory to disk-based caching), you only need to update the CacheDataSource implementation, not the repository.

By treating the cache as a separate data source, you adhere to Clean Architecture principles more closely, ensuring that your application's structure remains clear, modular, and maintainable.