Repository Pattern: The True Data Abstraction
Table of Contents
🏛️ Theory: The Guardian of Data
The Repository Pattern has a simple but vital purpose: Decouple business logic from the origin of data.
The Use Case (or ViewModel) asks: “Give me the users”. The Use Case doesn’t care if the users come from:
- A REST API (Retrofit)
- A local database (Room)
- A JSON file in assets
- An in-memory cache
This allows changing the data implementation without touching a single line of business logic.
🏗️ Anatomy of a Modern Repository
1. The Interface (Domain)
Defines what can be done, not how.
interface ProductRepository {
// Returns Flow for real-time updates
fun getProducts(): Flow<Result<List<Product>>>
// Suspend functions for one-shot operations
suspend fun refreshProducts(): Result<Unit>
suspend fun getProductById(id: String): Result<Product>
}
2. The Implementation (Data Layer)
Here lives the dirty logic of coordination.
class ProductRepositoryImpl @Inject constructor(
private val remote: ProductRemoteDataSource, // Retrofit
private val local: ProductLocalDataSource, // Room
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ProductRepository { ... }
🔄 Synchronization Strategies
The real value of the repository is in how it coordinates Local and Remote.
Strategy: Single Source of Truth (SSOT)
The local database is the ONLY truth.
- UI observes DB (Room Flow).
- When data is requested, Repo launches API call.
- If API responds, Repo saves to DB.
- Room automatically notifies UI with new data.
override fun getProducts(): Flow<Result<List<Product>>> {
return local.getProducts() // Flow from Room
.map { Result.Success(it) }
.onStart {
// Trigger side-effect refresh
try {
val remoteData = remote.fetch()
local.save(remoteData)
} catch (e: Exception) {
emit(Result.Error(e))
}
}
}
This strategy is robust because the app works Offline-First by default.
Strategy: Cache-Aside (Read with Fallback)
Useful for data that changes rarely or isn’t stored in DB.
- Check memory/disk.
- If missing or expired -> Call network.
- Return and save.
⚠️ Common Mistakes
- Exposing DTOs: The Repo must return Domain Models, not
NetworkResponse<UserDto>. Always map inside the Repo. - Business Logic: The Repo shouldn’t decide “if user is VIP, give discount”. That belongs in the Use Case. The Repo only stores and retrieves.
- Threading: The Repo must be “Main-Safe”. Use
withContext(Dispatchers.IO)to ensure calling repo from UI never blocks.
🎯 Conclusion
A good Repository is invisible. The domain layer trusts it blindly. By centralizing data access, you gain the ability to optimize (add in-memory cache, switch from SQL to NoSQL) without breaking the rest of the app. It is the key piece for long-term maintainability.
You might also be interested in
MVVM Model: The Invisible but Vital Data Layer
The 'Model' in MVVM is much more than data classes. Learn to design a robust model layer that survives UI and backend changes.
OpenSpec for Mobile Development: Spec-Driven Development in Android and Kotlin
How to apply OpenSpec in Android and Kotlin projects to keep AI agents aligned with architecture, with practical examples of change proposals, task validation, and living files.
Socratic Method Prompts: Breaking AI Sycophancy in Kotlin & Android Development
Learn how to stop LLMs from being compliant assistants and turn them into ruthless evaluators. Discover the mathematical anatomy of Socratic prompts for Android architecture, Kotlin Coroutines, and strict Spec-Driven Development.