Using the repository pattern for Android data access
Android applications rarely rely on just one source of data. A product screen may display records from SQLite or Room, current stock from a REST service, and user preferences from SharedPreferences. Without a clear boundary, every screen ends up deciding where data comes from and how failures should be handled.
The repository pattern creates a dedicated data access layer between the interface and those underlying sources. ViewModels and screens request domain models through a repository interface, while implementations coordinate local storage, remote APIs, caching, and error handling. This separation keeps presentation code focused on user interaction.
For Australian developers, this design also supports products serving different cities, regional areas, and retail requirements. A dependable data layer can cache stock information during unstable mobile coverage, retain prices in Australian dollars, and apply Australian postcode and GST rules in one consistent place.
Separating data access from interface logic
A repository acts as a mediator between the presentation layer and data providers such as Room databases, Retrofit services, and SharedPreferences. The ViewModel asks for products or requests a refresh, but it does not need to know whether the response came from the network or the local cache. That decision belongs inside the repository implementation.
This boundary improves testing because a fake repository can return controlled data without creating a database or making network requests. It also prevents duplicated loading logic across activities and fragments. SharedPreferences can remain appropriate for small settings, such as a selected store or an authentication token, without being forced to hold large application records.
Defining a repository contract
Start with a domain model that describes the information the application needs, independent of Android and JSON structures. The repository interface then exposes operations that make sense to the ViewModel rather than low-level database queries.
data class Product(
val id: String,
val name: String,
val priceCents: Int,
val gstInclusive: Boolean,
val available: Boolean
)
interface ProductRepository {
fun observeProducts(): Flow<List<Product>>
suspend fun refreshProducts(): Result<Unit>
suspend fun getProduct(id: String): Result<Product>
}
Room entities and API data transfer objects can differ because their responsibilities differ. A mapper converts remote responses into local entities and local entities into domain models. For Australian inventory data, the domain model may include integer minor currency units, a GST-inclusive flag, and Australian postcodes instead of relying on ambiguous string formatting.
Comparing data access approaches
The choice between direct access and a repository-based layer affects testing, offline behaviour, and maintenance. Direct access is quick for a small prototype, but its responsibilities become harder to isolate as an application gains screens and data sources.
| Concern | Direct data access from a ViewModel | Repository-based data access |
|---|---|---|
| Source selection | Each screen chooses an API or database | The repository coordinates the data source |
| Testing | Requires Android and network dependencies | Supports lightweight fake implementations |
| Offline behaviour | Often duplicated between screens | Centralised caching policy |
| Error handling | Tied to individual ViewModels | Normalised before reaching the interface |
| SharedPreferences | Commonly accessed throughout the app | Isolated behind a focused contract |
A repository does not mean every feature must use a remote service. Read-heavy catalogue data may work well with a local database and background refresh, while account preferences can use a separate settings repository. The useful pattern is a clear contract, a defined source of truth, and consistent behaviour when data cannot be retrieved.
Building an offline-first implementation
An offline-first repository presents cached data immediately and treats the network as an optional refresh mechanism. Room remains the source of truth for observable product data, while the API supplies newer records. If a request fails, the cached records stay available and the failure is returned as a Result.
class DefaultProductRepository(
private val api: ProductApi,
private val dao: ProductDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ProductRepository {
override fun observeProducts(): Flow<List<Product>> =
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun refreshProducts(): Result<Unit> =
withContext(ioDispatcher) {
try {
val products = api.fetchProducts()
dao.replaceAll(products.map { it.toEntity() })
Result.success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Result.failure(error)
}
}
override suspend fun getProduct(id: String): Result<Product> =
dao.findById(id)
?.let { Result.success(it.toDomain()) }
?: Result.failure(NoSuchElementException("Unknown product: $id"))
}
The implementation can also track when the last successful refresh occurred. This helps the interface display stale cached information during unreliable mobile broadband, a realistic concern for users travelling between regional Queensland and populated areas. Time values should be stored consistently, and money should use minor units rather than floating-point values.
Wiring the repository into a ViewModel
A ViewModel should depend on the repository interface so it can receive a production implementation through a factory or dependency injection framework. Exposing a Flow lets the interface collect database updates and request refreshes without repeatedly fetching the same records.
class ProductViewModel(
private val repository: ProductRepository
) : ViewModel() {
val products = repository.observeProducts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun refresh() {
viewModelScope.launch {
repository.refreshProducts()
}
}
}
Testing becomes more realistic when a development team in Sydney, Melbourne, or Brisbane uses the same ViewModel with a fake repository. At home, both the development device and computer must reach the same network; a practical guide to network extender setup can help with multi-level Wi-Fi coverage. Test suites should also cover no signal, slow responses, empty results, expired sessions, and cached Australian pricing before integration checks run.
A stable contract, a defined source of truth, and observable cached data keep Android data access predictable as screens, networks, and development teams grow.