๐Ÿ›๏ธ Android Architecture and Clean Code

Repository Pattern in Android

A repository is the boundary between your app's logic and where data comes from. Done well, ViewModels never know whether data came from the network, a database or memory, and you can test everything with simple fakes. Here's how to build one that holds up in production.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. What a repository is responsible for
  2. Implementation
  3. Error handling contract
  4. Testing with fakes
  5. How many repositories?
  6. Patterns in practice
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

Without a repository, ViewModels end up calling Retrofit and Room directly, deciding when to cache, mapping JSON and handling retries, all mixed with presentation logic. Every screen reinvents data access slightly differently. The repository pattern puts that logic in one place behind a clean interface.

What a repository is responsible for

  • Exposing data to the rest of the app, usually as Flow for observable data and suspend functions for one-shot operations.
  • Hiding data sources: network APIs, databases, DataStore, files, in-memory caches.
  • Resolving conflicts between sources and deciding the single source of truth.
  • Mapping DTOs and entities to models the app uses.
  • Being main-safe: callers can invoke it from any thread.
Repository with database as single source of truth ViewModels observe a Flow from the repository. The repository reads from the Room database, the single source of truth. On refresh, it fetches from the network API and writes results into the database, which re-emits to observers. ViewModelobserves Flow ArticleRepositoryinterface + impl Roomsource of truth Network APIRetrofit read refresh write
UI reads only from the database. The network's job is to update the database. That makes offline support and consistency much simpler.

Implementation

ArticleRepository.ktKotlin
interface ArticleRepository {
    fun observeArticles(): Flow<List<Article>>
    suspend fun refresh(force: Boolean = false)
    suspend fun toggleBookmark(id: String)
}

class DefaultArticleRepository @Inject constructor(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val clock: Clock,
    @IoDispatcher private val io: CoroutineDispatcher,
) : ArticleRepository {

    private val refreshMutex = Mutex()
    private var lastRefresh = Instant.EPOCH
    private val staleAfter = Duration.ofMinutes(15)

    override fun observeArticles(): Flow<List<Article>> =
        dao.observeAll().map { rows -> rows.map(ArticleEntity::toDomain) }

    override suspend fun refresh(force: Boolean) = withContext(io) {
        refreshMutex.withLock {                                 // avoid duplicate concurrent refreshes
            val now = clock.instant()
            if (!force && Duration.between(lastRefresh, now) < staleAfter) return@withLock
            val remote = api.getArticles()                      // may throw IOException / HttpException
            dao.upsertAll(remote.map(ArticleDto::toEntity))     // Room re-emits to observers
            lastRefresh = now
        }
    }

    override suspend fun toggleBookmark(id: String) = withContext(io) {
        dao.toggleBookmark(id)                                  // local change is immediate
    }
}

Room and Retrofit suspend functions are already main-safe. The withContext(io) here covers the mapping work and keeps the contract explicit.

Error handling contract

Pick one convention and document it on the interface:

  • Throw from suspend functions and let ViewModels catch specific exceptions (simple and idiomatic), or
  • Return typed results (Result or a sealed type) when callers must handle several expected outcomes.

For observed data, keep the Flow alive and emit cached data. Surface refresh failures separately, for example as a refresh result the ViewModel turns into a message.

Testing with fakes

FakeArticleRepository.kt (test)Kotlin
class FakeArticleRepository(initial: List<Article> = emptyList()) : ArticleRepository {
    private val articles = MutableStateFlow(initial)
    var refreshShouldFail = false
    var refreshCount = 0

    override fun observeArticles(): Flow<List<Article>> = articles
    override suspend fun refresh(force: Boolean) {
        refreshCount++
        if (refreshShouldFail) throw IOException("offline")
    }
    override suspend fun toggleBookmark(id: String) {
        articles.update { list -> list.map { if (it.id == id) it.copy(bookmarked = !it.bookmarked) else it } }
    }
}

Fakes are simple, readable implementations, often preferable to mocks for repositories because tests describe behaviour rather than call sequences.

How many repositories?

Organise repositories by data type or domain (ArticleRepository, UserRepository), not by screen. Several screens share a repository, and a screen may use several. Repositories can depend on other repositories when data is genuinely related, but avoid cycles.

Patterns in practice

Offline-first content

Database as the source of truth, with network refreshes and stale-while-revalidate. See Offline-First Architecture.

Settings

A repository over DataStore exposing Flow<Settings> and suspend setters.

Live data

A repository wrapping a WebSocket as a Flow, with reconnection hidden from callers.

Common mistakes

  • Returning Retrofit Response, DTOs or Room entities to the UI layer.
  • Repositories that hold UI state (loading flags for one screen).
  • Using the network as the source of truth and caching ad hoc, so screens disagree.
  • Creating new repository instances per screen, which defeats shared caches. Scope them as singletons via DI.

Frequently asked questions

Do I need an interface for every repository?

Interfaces make fakes and swapping implementations easy. For small apps you can start with a concrete class and extract an interface when testing demands it.

Repository or use case?

Repositories manage data access. Use cases hold reusable business logic that may combine several repositories. See Use Cases and Domain Layers.

Where does caching policy belong?

In the repository, or a data source it uses, so all callers get consistent behaviour.

Conclusion

A repository hides data sources, owns the source of truth, maps models and stays main-safe. Expose Flows for observable data and suspend functions for actions, and test callers with simple fakes.

Next step: wire repositories into ViewModels cleanly with Dependency Injection with Hilt.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Networking, persistence and offline sync are where real apps get complicated. Practise building a data layer that handles slow networks, failures and caching, not just the happy path.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp