Repository Pattern vs Direct API Calls in Android: When to Use Each Approach
Problem
I started building my first Android app with MVVM and Clean Architecture. Everyone said “use Repository pattern.” So I created a Repository class that just called my API:
class UserRepository @Inject constructor( private val api: UserApiService) { suspend fun getUser(id: String) = api.getUser(id)}Then I asked myself: Why does this exist? It’s just one extra layer that does nothing.
I found other developers on r/androiddev asking the same question. Some were confused about whether Repository is always required. Others created Repositories that just delegated to their APIs. Some wrapped everything in UseCases without adding value.
The problem was clear: I was following a pattern without understanding when it’s actually useful.
What happened?
I tried to understand what Repository pattern should do. I read blog posts that showed complex implementations with local cache, remote API, and sync logic. That made sense. But my app only has network calls - no database, no offline support.
I tried different approaches:
- First, I removed the Repository and called API directly from ViewModel
- Then I added UseCases for complex API logic
- Finally, I figured out when Repository is actually needed
Let me show you what I learned.
What is a Repository pattern?
A Repository is a class that hides where your data comes from. Your ViewModel shouldn’t know or care if data comes from network, database, or both.
The simplest repository just wraps a data source:
class UserRepository @Inject constructor( private val api: UserApiService) { suspend fun getUser(id: String): User { return api.getUser(id) }}But this doesn’t add value. It’s just a pass-through.
A Room DAO is already a repository:
@Daointerface UserDao { @Query("SELECT * FROM users WHERE id = :id") suspend fun getUser(id: String): User?}The DAO handles data access. You don’t need another Repository on top of it.
When Repository pattern is useful
Repository pattern shines when you have multiple data sources or complex data logic.
Case 1: Local cache plus remote API
This is the classic use case. You check local cache first, then fetch from network if needed:
class UserRepository @Inject constructor( private val api: UserApiService, private val dao: UserDao) { suspend fun getUser(id: String): User { // Check local cache first val cached = dao.getUser(id) if (cached != null && !isStale(cached)) { return cached }
// Fetch from network val remote = api.getUser(id)
// Update cache dao.insert(remote)
return remote }
private fun isStale(user: User): Boolean { val maxAge = Duration.ofDays(3) return Duration.between(user.updatedAt, Instant.now()) > maxAge }}This is useful when you want to ensure local data isn’t too old before showing it.
Case 2: Offline-first architecture
For apps that need to work offline, Repository handles the sync logic:
class NoteRepository @Inject constructor( private val api: NoteApiService, private val dao: NoteDao, private val networkMonitor: NetworkMonitor) { fun getNotes(): Flow<List<Note>> = channelFlow { // Always emit cached data first for instant UI dao.getAllNotes().collect { cached -> send(cached)
// If online, sync with server if (networkMonitor.isOnline) { try { val remote = api.getNotes() dao.syncNotes(remote) } catch (e: Exception) { // Keep showing cached data } } } }}The Repository hides the complexity of network state from your ViewModel.
Case 3: Merging multiple data sources
Sometimes you need data from different APIs combined:
class DashboardRepository @Inject constructor( private val userApi: UserApiService, private val ordersApi: OrderApiService, private val analyticsApi: AnalyticsApiService) { suspend fun getDashboardData(): DashboardData { // Fetch from multiple APIs in parallel val user = userApi.getCurrentUser() val orders = ordersApi.getRecentOrders() val stats = analyticsApi.getStats()
return DashboardData(user, orders, stats) }}Your ViewModel just calls getDashboardData() and gets everything.
When you don’t need a Repository
For many simple apps, Repository is over-engineering.
Case 1: Simple network-only apps
If your app only makes API calls with no local storage, you can skip Repository:
@HiltViewModelclass ProfileViewModel @Inject constructor( private val api: ProfileApiService) : ViewModel() {
private val _profile = MutableStateFlow<Profile?>(null) val profile: StateFlow<Profile?> = _profile
fun loadProfile(userId: String) { viewModelScope.launch { _profile.value = api.getProfile(userId) } }}This is simpler and cleaner for basic apps.
Case 2: Use UseCases for complex API logic
You can skip Repository but use UseCases to encapsulate complex API logic:
class SearchProductsUseCase @Inject constructor( private val api: ProductApiService) { suspend operator fun invoke(query: String, filters: Filters): SearchResult { val products = api.searchProducts(query, filters.toApiParams()) val suggestions = api.getSearchSuggestions(query)
return SearchResult( products = products.sortedBy { it.relevance }, suggestions = suggestions.take(5) ) }}The UseCase handles the complexity without needing a Repository.
Case 3: RemoteDataSource as lightweight alternative
Instead of full Repository, you can use a lighter abstraction:
class UserRemoteDataSource @Inject constructor( private val api: UserApiService) { suspend fun getUsers(): List<User> { return try { api.getUsers() } catch (e: HttpException) { throw UserException.ApiError(e.code()) } catch (e: IOException) { throw UserException.NetworkError } }}This adds error handling and exception mapping without full Repository complexity.
Decision framework
Here’s a simple way to decide:
Need to fetch data?│├─ Need local caching?│ ││ ├─ Yes│ │ ││ │ ├─ Also need remote API?│ │ │ ├─ Yes → Use Repository Pattern│ │ │ │ (handles cache vs network logic)│ │ │ ││ │ │ └─ No → Room DAO is enough│ │ ││ │ └─ No│ │ ││ │ └─ Is API logic complex?│ │ ├─ Yes → Use UseCase layer│ │ └─ No → Call API directly from ViewModelThis decision table summarizes it:
| Scenario | Recommended Approach |
|---|---|
| Network-only, simple API | Direct API calls in ViewModel |
| Network-only, complex API | UseCase + API |
| Local DB only | Room DAO (DAO IS your repository) |
| Local + Remote with caching | Repository Pattern |
| Offline-first app | Repository + Sync logic |
| Multiple data sources | Repository |
Clean Architecture context
Repository fits into the Clean Architecture layer structure:
UI Layer (Compose/XML)│ViewModels│UseCases (optional but recommended)│Repository (conditional - see flowchart)│Data Sources (Retrofit API, Room DAO, etc.)Key principles:
- Dependency Rule: Inner layers don’t know about outer layers
- Repository is a Contract: It defines WHAT data you need, not WHERE it comes from
- UseCases for Business Logic: Don’t put business rules in Repository
Clean Architecture becomes over-engineering when you add unnecessary layers:
OVER-ENGINEERED:ViewModel → UseCase → Repository → RemoteDataSource → RetrofitAPI
APPROPRIATE:ViewModel → Repository → (Room + Retrofit)Common mistakes to avoid
Mistake 1: Empty repositories
// BAD: Repository that just delegates - adds no valueclass UserRepository @Inject constructor( private val api: UserApiService) { suspend fun getUser(id: String) = api.getUser(id) // Why does this exist?}Either remove it or add real value through error handling or caching.
Mistake 2: Business logic in Repository
// BAD: Business logic doesn't belong in Repositoryclass OrderRepository { suspend fun calculateDiscount(order: Order): Double { // This is business logic - put in UseCase! }}Repository only handles data access. Business logic goes in UseCases.
Mistake 3: Over-abstracting data sources
BAD: Too many layersRepository → RemoteDataSource → ApiWrapper → RetrofitService
GOOD: Direct and simpleRepository → (RetrofitService + RoomDao)Keep your layers minimal and purposeful.
Summary
In this post, I explained when to use Repository pattern versus direct API calls in Android. The key point is that Repository is valuable when you have multiple data sources or complex caching logic. For simple network-only apps, direct API calls or UseCases are often sufficient.
A Room DAO is already a repository - no need to wrap it. Don’t create empty repositories that just delegate. Use the decision flowchart to choose the right approach based on your app’s needs.
Clean Architecture is a guide, not a religion. Adapt the patterns to your specific requirements rather than following them blindly.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments