Applying Repository and Data Source Patterns in Android Development
Course: Android App Development
Topic: Applying Repository and Data Source Patterns
Course Link: Android App Development Course
Demo Link: Register for a Course Demo
Website: JustAcademy Official Website
1. Introduction to Repository and Data Source Patterns
Repository and Data Source patterns are important software design techniques used to organize data access in Android applications. They help separate business logic and UI-related operations from the details of retrieving, storing, and updating information.
Modern Android applications often work with multiple data sources, including REST APIs, local databases, shared preferences, files, and in-memory caches. Managing all these sources directly inside an Activity, Fragment, or ViewModel can make an application difficult to maintain and test.
The Repository pattern provides a consistent interface through which application components access data. Data Source patterns organize the actual interactions with individual storage systems or services.
These patterns are frequently used with the Model-View-ViewModel (MVVM) architecture, Kotlin Coroutines, Flow, Room, Retrofit, and dependency injection frameworks such as Hilt.
Learning Objectives
- Understand the Repository and Data Source patterns.
- Identify the responsibilities of repositories and data sources.
- Learn how repositories integrate with MVVM architecture.
- Implement local and remote data sources using Kotlin.
- Combine Room Database and Retrofit in a data layer.
- Manage asynchronous data operations using Coroutines and Flow.
- Apply caching, error handling, and offline-first strategies.
- Write unit tests using fake repositories and data sources.
2. What Is the Repository Pattern?
The Repository pattern is a software design pattern that provides a centralized interface for accessing and managing application data. It hides the details of where data comes from and how it is retrieved.
For example, an Android shopping application may retrieve product information from a remote API and store selected products in a local Room database. Instead of requiring the ViewModel to communicate directly with both systems, the ViewModel interacts with a ProductRepository.
The repository determines which data source should be used, coordinates the required operations, and returns the result to the caller.
Responsibilities of a Repository
- Provide a consistent API for accessing application data.
- Coordinate local and remote data sources.
- Encapsulate data retrieval and persistence logic.
- Apply caching and synchronization policies when needed.
- Transform data between source-specific and domain-friendly representations.
- Handle or propagate data-related errors using a defined strategy.
- Reduce dependencies between ViewModels and infrastructure components.
Example of a Repository
class ProductRepository(
private val productDataSource: ProductDataSource
) {
suspend fun getProducts(): List<Product> {
return productDataSource.getProducts()
}
}
In this example, the repository delegates product retrieval to a data source. The ViewModel does not need to know how the data source obtains the products.
3. What Is a Data Source?
A Data Source is a component responsible for interacting with a particular data provider. It encapsulates the implementation details of accessing a database, web service, file system, cache, or another storage mechanism.
A data source generally focuses on a specific mechanism of data access rather than deciding the overall data strategy for the application.
Common Types of Data Sources
| Data Source |
Purpose |
Android Example |
| Remote Data Source |
Communicates with remote services |
Retrofit API service |
| Local Data Source |
Reads and writes persistent local data |
Room DAO |
| Memory Data Source |
Keeps data temporarily in memory |
In-memory cache |
| Preferences Data Source |
Stores small application settings |
DataStore |
| File Data Source |
Reads and writes files |
Internal app storage |
Example of a Data Source Interface
interface ProductDataSource {
suspend fun getProducts(): List<Product>
}
This interface defines the operation required to retrieve products. Different implementations can obtain those products from a remote API, a local database, or an in-memory collection.
4. Repository Pattern vs. Data Source Pattern
| Feature |
Repository |
Data Source |
| Main purpose |
Coordinates access to application data |
Interacts with a particular data provider |
| Scope |
May coordinate several sources |
Usually focuses on one source or mechanism |
| Business requirements |
Can apply data-selection and synchronization policies |
Primarily performs source-specific operations |
| Examples |
ProductRepository, UserRepository |
ProductApiDataSource, ProductLocalDataSource |
| Dependencies |
Depends on data source interfaces or implementations |
Depends on APIs, DAOs, storage, or other infrastructure |
| Testing |
Test coordination and data policies |
Test individual data-access behavior |
The main distinction is that a repository provides the application's data-access boundary, while a data source performs the underlying access operation. The exact division of responsibilities depends on the application's architecture.
5. How Repository and Data Source Patterns Work Together
In an MVVM-based Android application, the ViewModel generally requests data from a repository. The repository coordinates one or more data sources and returns the result. The ViewModel then exposes the resulting information through observable UI state.
Data Flow Diagram
+----------------------------+
| View |
| Activity / Fragment / UI |
+-------------+--------------+
|
v
+----------------------------+
| ViewModel |
| UI state and user actions |
+-------------+--------------+
|
v
+----------------------------+
| Repository |
| Data coordination/policies |
+-------------+--------------+
|
+-----+-----+
| |
v v
+-------------+ +-------------+
| Remote Data | | Local Data |
| Source | | Source |
| Retrofit | | Room / DAO |
+------+------+ +------+------+
| |
v v
+-------------+ +-------------+
| Remote API | | Local DB |
+-------------+ +-------------+
For a simple application, a repository may delegate directly to a single source. For a more complex application, it may coordinate several sources and implement caching, refresh, or offline-first behavior.
Typical Execution Sequence
- The user opens a product screen.
- The View forwards the action to the ViewModel.
- The ViewModel requests products from the repository.
- The repository determines the appropriate data-access strategy.
- A data source retrieves information from the API, database, or cache.
- The repository returns the result or exposes a stream of data.
- The ViewModel updates its UI state.
- The View observes the state and renders the results.
6. Creating a Product Model
Begin by defining the data structure used by the application. Kotlin data classes are suitable for representing product information.
data class Product(
val id: Int,
val name: String,
val price: Double,
val category: String
)
This model represents a product with an identifier, name, price, and category. In a real application, separate API DTOs, database entities, and domain models may be appropriate when their requirements differ.
7. Implementing the Remote Data Source
A remote data source communicates with a backend service to retrieve or update information. Retrofit is a popular Android library for defining HTTP API interfaces.
7.1 Define the API Service
import retrofit2.http.GET
interface ProductApiService {
@GET("products")
suspend fun getProducts(): List<ProductDto>
}
data class ProductDto(
val id: Int,
val name: String,
val price: Double,
val category: String
)
The API service defines the HTTP request. The suspend modifier allows the request to be called from a coroutine. The DTO represents the response structure expected from the backend.
7.2 Define the Remote Data Source
interface ProductRemoteDataSource {
suspend fun getProducts(): List<ProductDto>
}
class ProductRemoteDataSourceImpl(
private val apiService: ProductApiService
) : ProductRemoteDataSource {
override suspend fun getProducts(): List<ProductDto> {
return apiService.getProducts()
}
}
This implementation delegates the request to Retrofit. In a production application, the API response should be validated and mapped appropriately, and network errors should be handled according to the application's error policy.
8. Implementing the Local Data Source with Room
Room provides an abstraction over SQLite for storing structured data locally. A local data source can use Room entities and DAOs to read and write records.
8.1 Create a Room Entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "products")
data class ProductEntity(
@PrimaryKey val id: Int,
val name: String,
val price: Double,
val category: String
)
The @Entity annotation identifies a database table, while @PrimaryKey identifies the column used to uniquely identify each product.
8.2 Create a DAO
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ProductDao {
@Query("SELECT * FROM products")
fun observeProducts(): Flow<List<ProductEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProducts(
products: List<ProductEntity>
)
@Query("DELETE FROM products")
suspend fun clearProducts()
}
The DAO defines database operations. Room generates the implementation at compile time. The Flow returned by observeProducts() emits updated query results when the underlying table changes.
8.3 Create the Local Data Source
interface ProductLocalDataSource {
fun observeProducts(): Flow<List<ProductEntity>>
suspend fun saveProducts(products: List<ProductEntity>)
}
class ProductLocalDataSourceImpl(
private val productDao: ProductDao
) : ProductLocalDataSource {
override fun observeProducts(): Flow<List<ProductEntity>> {
return productDao.observeProducts()
}
override suspend fun saveProducts(
products: List<ProductEntity>
) {
productDao.insertProducts(products)
}
}
The local data source encapsulates Room-specific operations. Other application components can interact with the interface without directly depending on the DAO.
9. Implementing the Repository
The repository connects the remote and local data sources. A common approach is to treat the local database as the source observed by the UI and refresh it with remote data when necessary.
9.1 Map Between Data Models
fun ProductDto.toEntity(): ProductEntity {
return ProductEntity(
id = id,
name = name,
price = price,
category = category
)
}
fun ProductEntity.toDomain(): Product {
return Product(
id = id,
name = name,
price = price,
category = category
)
}
Mapping functions convert network DTOs into database entities and database entities into domain models. Keeping these transformations explicit reduces accidental coupling between external API formats, database schemas, and UI-facing models.
9.2 Create the Repository Interface
interface ProductRepository {
fun observeProducts(): Flow<List<Product>>
suspend fun refreshProducts()
}
The interface describes the operations available to the application. The ViewModel can depend on this interface instead of depending on Retrofit or Room directly.
9.3 Implement the Repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ProductRepositoryImpl(
private val remoteDataSource: ProductRemoteDataSource,
private val localDataSource: ProductLocalDataSource
) : ProductRepository {
override fun observeProducts(): Flow<List<Product>> {
return localDataSource.observeProducts()
.map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun refreshProducts() {
val remoteProducts = remoteDataSource.getProducts()
val entities = remoteProducts.map {
it.toEntity()
}
localDataSource.saveProducts(entities)
}
}
This implementation observes products from Room and refreshes the local database using remote API results. Because Room emits updated query results, the UI can receive the refreshed products without directly observing the network request.
Important implementation details:
- The sample assumes that the Retrofit API, DTO, Room database, and mapping functions have been configured correctly.
- The example does not yet include retry logic, pagination, request deduplication, or a complete error model.
- For production use, consider replacing the product collection transactionally or using Room transactions when multiple database operations must succeed together.
- Do not automatically clear cached data after a network failure. An offline-first application should generally preserve valid local data.
10. Connecting the Repository to a ViewModel
The ViewModel requests data through the repository and exposes observable state to the View. It does not need to know how Retrofit requests are created or how Room stores products.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ProductViewModel(
private val repository: ProductRepository
) : ViewModel() {
val products: StateFlow<List<Product>> =
repository.observeProducts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun refresh() {
viewModelScope.launch {
repository.refreshProducts()
}
}
}
The ViewModel exposes a StateFlow containing the current product list. Calling refresh() requests fresh data from the repository. In this simplified example, refresh errors are not exposed to the UI; a production implementation should model loading and error states explicitly.
11. Implementing Loading and Error States
Applications should provide clear feedback while refreshing data or when a request fails. A single UI state object can represent loading, content, and error information.
data class ProductUiState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null
)
The ViewModel can update this state before and after a refresh operation. It should preserve cached products when appropriate, so a failed network request does not unnecessarily remove content already available to the user.
A robust implementation should distinguish expected failures, such as no network connection, from programming errors. It should also handle coroutine cancellation correctly rather than reporting cancellation as an ordinary network error.
12. Choosing Between Local and Remote Data Sources
A repository may use different data sources depending on the application's requirements. The strategy should be deliberate rather than scattered across UI components.
| Strategy |
How It Works |
Suitable Scenario |
| Remote-first |
Requests fresh information from the server |
Data that must be current and requires connectivity |
| Local-first |
Reads local data before attempting a refresh |
Apps that need responsive screens and offline access |
| Cache-first |
Returns cached information when available |
Frequently accessed data where temporary staleness is acceptable |
| Network-only |
Retrieves information from the server without local persistence |
Small or short-lived requests where caching is unnecessary |
| Offline-first |
Uses local storage as the primary observable source and synchronizes with the network |
Apps that must remain useful without a connection |
13. Understanding Offline-First Architecture
An offline-first architecture aims to provide a useful experience even when the device temporarily loses its network connection. In many Android applications, the local database acts as the primary source of data observed by the UI.
How Offline-First Data Flow Works
- The UI observes data stored in Room.
- The repository requests an update from the remote service.
- The remote data source retrieves the latest available information.
- The repository validates and maps the response.
- The repository writes the updated information to Room.
- Room emits the new data through Flow.
- The ViewModel exposes the updated state to the UI.
If the network request fails, the application can continue displaying the last available local data and communicate that the information may not be current.
Benefits of Offline-First Design
- Improves perceived responsiveness.
- Allows previously downloaded information to remain accessible.
- Reduces unnecessary repeated network requests.
- Provides a consistent data source for UI observation.
- Supports more resilient user experiences.
14. Caching and Data Synchronization
Caching stores previously retrieved data so that it can be reused without always requesting the original source. Repositories are a natural place to coordinate caching policies because they understand how application data sources work together.
Common Caching Techniques
- In-memory caching: Keeps frequently accessed data in memory for quick retrieval.
- Database caching: Stores data in Room so it remains available after the application closes.
- Time-based refresh: Refreshes data after a defined period.
- Conditional requests: Uses server-supported mechanisms such as ETags when appropriate.
- Manual refresh: Lets users request updated information.
Data synchronization can be more complicated when users edit information offline. Applications may need conflict-resolution rules, pending-write tracking, retry policies, and background synchronization. WorkManager can be useful for deferrable background tasks that should persist across app restarts.
15. Error Handling in Repository and Data Source Patterns
Errors can occur at different layers, including network communication, JSON parsing, database access, and data conversion. Each layer should handle errors at the appropriate boundary.
| Error Type |
Possible Cause |
Recommended Handling |
| Network error |
No connection or request timeout |
Preserve cached data and provide retry feedback |
| HTTP error |
Server returns an unsuccessful status |
Map the response to an appropriate application error |
| Parsing error |
Unexpected response format |
Validate data and report a meaningful failure |
| Database error |
Constraint violation or storage issue |
Handle the database exception at the data boundary |
| Mapping error |
Unexpected or invalid values |
Validate inputs and use explicit conversion rules |
| Cancellation |
Coroutine scope is cancelled |
Propagate cancellation rather than treating it as an ordinary failure |
Consider using a result type or sealed class to represent successful results and expected failures. Avoid returning misleading default values when a real error has occurred.
16. Dependency Injection for Repositories and Data Sources
Dependency injection allows the application to supply dependencies to a class rather than forcing that class to construct all its dependencies internally. It simplifies testing and makes implementations easier to replace.
For example, a ProductRepository implementation may receive ProductRemoteDataSource and ProductLocalDataSource through its constructor. A dependency-injection framework can construct these objects and provide the same repository wherever it is required.
class ProductRepositoryImpl(
private val remoteDataSource: ProductRemoteDataSource,
private val localDataSource: ProductLocalDataSource
) : ProductRepository {
// Repository implementation
}
Constructor injection is useful even without a framework. For larger Android projects, Hilt can help manage scopes, bindings, and object creation. Interfaces should be introduced when they create a meaningful boundary, not merely to increase the number of classes.
17. Unit Testing Repositories and Data Sources
Testing verifies that data operations behave as expected. Repository tests can use fake data sources to simulate network responses and local storage without depending on a real backend or database.
What Should Be Tested?
- Successful data retrieval.
- Correct mapping between DTOs, entities, and domain models.
- Correct coordination between remote and local sources.
- Preservation of cached data when refreshing fails.
- Expected error propagation.
- Database operations and conflict-handling behavior.
- ViewModel state updates after repository operations.
Example of a Fake Remote Data Source
class FakeProductRemoteDataSource(
private val products: List<ProductDto>
) : ProductRemoteDataSource {
override suspend fun getProducts(): List<ProductDto> {
return products
}
}
This fake implementation returns predefined products, allowing a test to verify repository behavior without making an actual HTTP request.
Testing Best Practices
- Use fake implementations to control test data.
- Use coroutine testing utilities for suspending functions and Flow.
- Test error cases as well as successful results.
- Use Room in-memory databases for local database integration tests.
- Keep unit tests independent of external network availability.
18. Common Mistakes and Troubleshooting
| Problem |
Cause |
Solution |
| ViewModel directly accesses Room and Retrofit |
Data-access responsibilities are mixed into the UI layer |
Move data coordination into a repository |
| Repository contains too much unrelated logic |
One repository manages unrelated application features |
Organize repositories around meaningful data responsibilities |
| Data is not refreshed in the UI |
UI observes a different source from the one being updated |
Ensure the UI observes the intended repository stream or state |
| Cached data disappears after a network error |
Local data is cleared before remote retrieval succeeds |
Preserve existing data until a valid replacement is available |
| Duplicate API calls |
Refresh operations are triggered repeatedly |
Review lifecycle events, event handling, and request deduplication |
| Tests depend on the real server |
External dependencies are not replaceable |
Inject interfaces and use fake data sources |
| Database records become inconsistent |
Related writes are not grouped transactionally |
Use suitable Room transactions and explicit update policies |
19. Best Practices for Production Applications
- Define clear responsibilities for repositories and data sources.
- Keep API, database, and domain models separate when their structures differ.
- Use constructor injection to make dependencies explicit.
- Expose Flow or other observable types when the UI needs ongoing updates.
- Use suspend functions for one-time asynchronous data operations.
- Keep database operations off the main thread.
- Choose cache invalidation and synchronization policies based on business requirements.
- Use structured error handling and preserve coroutine cancellation.
- Validate remote data before persisting it when necessary.
- Use database transactions for logically related updates.
- Protect sensitive information and avoid logging private data.
- Write tests for success, failure, mapping, and synchronization behavior.
- Avoid unnecessary abstraction when a simple implementation meets the requirements.
20. Practical Project: Product Catalog Application
Build an Android product catalog that retrieves product information from an API, stores it locally, and displays it through a ViewModel.
Project Features
- Retrieve product information using Retrofit.
- Store product records in Room.
- Implement separate remote and local data source interfaces.
- Create a repository to coordinate the two sources.
- Expose product data through Flow and StateFlow.
- Display loading, success, empty, and error states.
- Support refreshing the catalog.
- Continue displaying cached products when offline.
- Write tests for mapping, refresh behavior, and failure scenarios.
Suggested Package Structure
com.example.productcatalog
|
+-- data
| +-- remote
| | +-- ProductApiService.kt
| | +-- ProductRemoteDataSource.kt
| +-- local
| | +-- ProductDao.kt
| | +-- ProductEntity.kt
| | +-- ProductLocalDataSource.kt
| +-- repository
| +-- ProductRepositoryImpl.kt
| +-- ProductMappers.kt
|
+-- domain
| +-- model
| | +-- Product.kt
| +-- repository
| +-- ProductRepository.kt
|
+-- ui
| +-- catalog
| +-- ProductViewModel.kt
| +-- ProductUiState.kt
| +-- ProductScreen.kt
|
+-- di
+-- AppModule.kt
This is one possible structure for a feature-oriented or layered Android application. Smaller projects may combine some packages while preserving the separation between UI and data access.
21. Practice Exercises
- Explain the difference between a repository and a data source.
- Create a UserRepository that returns user details.
- Implement a local data source using a Room DAO.
- Create a remote data source using a Retrofit API service.
- Write mapping functions for DTOs, entities, and domain models.
- Modify a repository to coordinate local and remote sources.
- Expose database updates through Flow.
- Implement refresh and retry functionality in a ViewModel.
- Simulate a network failure and preserve cached data.
- Write unit tests using fake local and remote data sources.
22. Interview Questions and Answers
Q1. What is the Repository pattern in Android?
Answer: It provides a consistent interface for accessing application data and hides the details of individual data sources from components such as ViewModels.
Q2. What is a Data Source pattern?
Answer: A data source encapsulates access to a particular provider, such as a remote API, Room database, file system, or in-memory cache.
Q3. What is the difference between a repository and a DAO?
Answer: A DAO defines database operations, while a repository coordinates application data access and may combine information from multiple sources.
Q4. Why should a ViewModel not directly access Retrofit?
Answer: Direct access couples UI-related logic to network implementation details. A repository provides a clearer boundary and makes testing and data-source changes easier.
Q5. Can a repository use multiple data sources?
Answer: Yes. A repository can coordinate local databases, remote APIs, caches, and other providers according to the application's data requirements.
Q6. How does Room support repository architecture?
Answer: Room provides structured local persistence through entities, DAOs, and database classes. A local data source can encapsulate these operations for use by a repository.
Q7. What is the purpose of Flow in a repository?
Answer: Flow can expose asynchronous streams of data. For example, a repository can expose a Room query as a Flow so consumers receive updates when database records change.
Q8. How should a repository handle network failures?
Answer: It should follow a defined error strategy, such as returning an expected failure result, propagating an exception, or preserving cached data while reporting refresh status.
Q9. What is offline-first architecture?
Answer: It prioritizes local data as the primary source observed by the UI while synchronizing with remote services when possible.
Q10. How do you test a repository?
Answer: Use fake data sources to control inputs and verify data mapping, source coordination, successful operations, and error behavior. Use integration tests for real database behavior when needed.
23. Quick Revision Checklist
- Repository: central interface for application data access.
- Data source: component that interacts with a specific provider.
- Remote source: commonly communicates with a backend through Retrofit.
- Local source: commonly accesses persistent data through Room.
- DAO: defines database operations.
- Flow: supports ongoing asynchronous data streams.
- Coroutines: support suspending and asynchronous operations.
- Mapping: converts between network, database, and domain models.
- Caching: reduces unnecessary requests and improves responsiveness.
- Offline-first: keeps useful local data available without connectivity.
- Testing: verifies repository behavior using controlled dependencies.
24. Conclusion
Repository and Data Source patterns help Android developers build a well-organized data layer. The repository coordinates data access, while individual data sources encapsulate interactions with systems such as remote APIs and local databases.
When combined with MVVM, Kotlin Coroutines, Flow, Room, and Retrofit, these patterns improve separation of concerns, testability, and maintainability. Effective implementations also require thoughtful error handling, caching policies, synchronization strategies, and dependency management.
Practice by building a product catalog or user profile application that uses a repository to coordinate local and remote data. This will help develop the skills needed to implement scalable data layers in real-world Android projects.
25. Course Resources
Explore the course: Android App Development Training
Register for a demo: Register for an Android App Development Demo
Visit the website: JustAcademy Official Website