Quizzr Logo

Mobile State Management

Decoupling Data Logic with the Local Repository Pattern

Understand how to architect a repository layer that mediates between different local storage types and provides a clean API for the UI.

Mobile DevelopmentIntermediate12 min read

The Architecture of Data Isolation

Modern mobile applications face a significant challenge when trying to manage data from multiple sources. When a view component interacts directly with a network client or a database driver, it becomes tightly coupled to the underlying implementation. This coupling makes it incredibly difficult to swap out storage technologies or introduce complex caching logic without rewriting large portions of the UI code.

The repository pattern acts as a mediator between the domain layer and the data mapping layer. It provides a clean API for the rest of the application to access data while hiding the details of where that data originates. By centralizing data access, developers can implement sophisticated logic for local caching and offline support in one place.

One primary benefit of this isolation is the ability to maintain a consistent state across different screens. Without a repository, two different view models might fetch the same user profile and hold slightly different versions of that data in memory. This leads to UI inconsistencies where a name change on one screen is not reflected on another, creating a jarring experience for the user.

The repository serves as the single source of truth for the application. It ensures that any data requested by the UI layer is the most accurate and up-to-date version available. This architecture also simplifies the testing process because developers can mock the repository to return predictable data without needing to set up a real database or network environment.

The UI should never care whether data comes from a local SQLite database or a remote REST API; it should only care that it requested a specific resource and received it.
  • Decouples the UI from data sourcing logic to improve maintainability
  • Centralizes caching strategies to ensure consistent data across the app
  • Simplifies unit testing by allowing easy mocking of data sources
  • Provides a natural location for data transformation and mapping

Defining the Repository Interface

Before writing implementation code, it is essential to define a clear contract for the repository. This interface should use domain-specific language that reflects the needs of the application rather than the structure of the database. For example, a method should be named getAccountDetails rather than fetchFromSqliteByUserId.

Using an interface allows the application to remain flexible. You can create a fake implementation for early development stages and later swap it for a production-ready version that interacts with real APIs. This approach also makes it easier to implement different behaviors for different build flavors, such as a mock repository for automated UI tests.

kotlinUserRepository Interface
1interface UserRepository {
2    // Returns a stream of data that the UI can observe
3    fun getUserProfile(userId: String): Flow<UserProfile>
4
5    // Triggers a background refresh of the data
6    suspend fun refreshUserProfile(userId: String): Result<Unit>
7
8    // Updates local and remote state simultaneously
9    suspend fun updateDisplayName(newName: String): Result<Unit>
10}

Implementing Reactive Data Flows

To build a truly responsive mobile app, the repository should expose data using reactive streams. Instead of returning a one-time value, the repository provides a continuous flow of data that updates whenever the underlying source changes. This allows the UI to automatically re-render when a background sync completes or a user makes a local edit.

The reactive approach eliminates the need for manual polling or complex callback chains. When the repository detects a change in the local database, it pushes the new state through the stream to all active observers. This creates a seamless experience where the application feels alive and always in sync with the latest information.

Managing these streams requires careful consideration of the lifecycle of the data. Repositories should typically use cold streams that only execute when there is an active subscriber. This prevents unnecessary resource consumption when the user is not actively viewing a particular piece of information.

Error handling in reactive streams is another critical component of the repository layer. The repository must capture low-level exceptions from the network or disk and transform them into meaningful domain-level results. This ensures the UI layer receives actionable information, such as a connection timeout or an authentication failure, rather than raw stack traces.

The Single Source of Truth Strategy

The most effective way to implement a reactive repository is the single source of truth pattern. In this model, the UI only ever observes the local database. When the UI triggers a refresh, the repository fetches data from the network and writes it directly to the database.

Once the database is updated, the reactive stream automatically notifies the UI of the change. This flow ensures that the application remains functional even when the device is offline. The user sees the cached data immediately while the repository attempts to fetch newer data in the background.

kotlinImplementing the Reactive Flow
1class UserProfileRepository(
2    private val localDataSource: UserDao,
3    private val remoteDataSource: UserApi
4) : UserRepository {
5
6    // UI observes this flow to get real-time updates from the DB
7    override fun getUserProfile(userId: String): Flow<UserProfile> {
8        return localDataSource.observeUser(userId).map { entity ->
9            entity.toDomainModel()
10        }
11    }
12
13    // Network logic is hidden from the UI
14    override suspend fun refreshUserProfile(userId: String): Result<Unit> {
15        return try {
16            val response = remoteDataSource.fetchUser(userId)
17            localDataSource.saveUser(response.toEntity())
18            Result.success(Unit)
19        } catch (e: Exception) {
20            Result.failure(e)
21        }
22    }
23}

Local Persistence and Synchronization

Choosing the right local storage technology is a foundational decision for the repository layer. Relational databases like SQLite are ideal for complex data sets with multiple relationships. For simpler key-value pairs or user preferences, lighter solutions like encrypted shared preferences or modern data stores are often more appropriate.

Data persistence is not just about saving bits to disk; it is about ensuring data integrity and durability. The repository must handle database migrations gracefully to prevent data loss when the application schema evolves. It also needs to manage file system constraints, such as limited storage space or file corruption scenarios.

Synchronization logic often involves complex trade-offs between data freshness and battery life. A repository might implement a strategy that only fetches new data if the local cache is older than a specific threshold. This reduces network traffic and improves the overall responsiveness of the application during intermittent connectivity.

Handling deletions and updates in an offline-capable app requires a robust synchronization strategy. One common approach is using soft deletes, where records are marked as deleted locally and only removed from the remote server when a connection is available. The repository coordinates these flags to ensure the UI reflects the user intent while the background sync catches up.

Caching Policies and Expiration

A well-designed repository implements logic to decide when data is considered stale. This can be based on a simple timer or more complex signals like network status changes. By maintaining a timestamp for each cached record, the repository can determine whether to serve local data or force a network fetch.

Implementing a stale-while-revalidate pattern provides the best of both worlds. The repository immediately returns the cached data to keep the UI responsive, while simultaneously launching a background request to fetch fresh data. If the network call succeeds, the local cache is updated and the UI is refreshed again automatically.

kotlinStale-While-Revalidate Logic
1suspend fun getDashboardData(forceRefresh: Boolean): Flow<Dashboard> {
2    val cachedData = localDb.getDashboard()
3    
4    // If cache is missing or too old, we must fetch
5    val shouldFetch = forceRefresh || cachedData == null || isExpired(cachedData.timestamp)
6    
7    return if (shouldFetch) {
8        // Emit cache first, then fetch network
9        flowOf(cachedData).onCompletion { 
10            syncWithNetwork() 
11        }
12    } else {
13        flowOf(cachedData)
14    }
15}

Data Mapping and Domain Separation

A common pitfall in mobile development is using the same data models for network responses, database entities, and UI states. This creates a ripple effect where a minor change in the backend API requires changes throughout the entire application. The repository layer prevents this by performing data mapping between these layers.

The repository should convert raw API response objects into clean domain models that represent the business logic of the app. This mapping process allows the developer to clean up the data, provide default values for null fields, and transform data formats. For example, a repository might convert a Unix timestamp from the API into a formatted date string for the UI.

By maintaining separate models, the repository acts as a protective buffer. If the backend team decides to rename a field in the JSON response, only the mapping logic inside the repository needs to change. The rest of the application remains unaware of the change, significantly reducing the cost of maintenance and the risk of introducing bugs.

Effective mapping also enables the application to handle partial data more effectively. If a network response is missing certain optional fields, the repository can fill those gaps with existing data from the local database. This ensures that the domain model returned to the UI is always complete and valid, preventing null pointer exceptions and UI glitches.

Separating Concerns with Mappers

To keep the repository implementation clean, it is best to move mapping logic into dedicated mapper classes or extension functions. These mappers handle the tedious task of copying fields between different object types. This separation makes the repository logic easier to read and the mapping logic easier to unit test in isolation.

Mappers also provide a great place to handle data validation. If the repository receives data from the network that does not meet the domain requirements, the mapper can throw an exception or return a default state. This ensures that invalid data never pollutes the local database or the UI layer.

kotlinData Mapping Implementation
1// Extension functions to map between layers
2fun UserResponseEntity.toDomain(): UserProfile {
3    return UserProfile(
4        id = this.externalId,
5        fullName = "${this.firstName} ${this.lastName}",
6        avatarUrl = this.profilePictureUrl ?: DEFAULT_AVATAR
7    )
8}
9
10fun UserProfile.toDatabaseEntity(): UserLocalEntity {
11    return UserLocalEntity(
12        dbId = this.id,
13        name = this.fullName,
14        lastSync = System.currentTimeMillis()
15    )
16}

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.