Skip to content

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:

UserRepository.kt
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:

  1. First, I removed the Repository and called API directly from ViewModel
  2. Then I added UseCases for complex API logic
  3. 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:

SimpleRepository.kt
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:

UserDao.kt
@Dao
interface 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:

CachedUserRepository.kt
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:

OfflineNoteRepository.kt
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:

DashboardRepository.kt
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:

DirectApiViewModel.kt
@HiltViewModel
class 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:

SearchProductsUseCase.kt
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:

UserRemoteDataSource.kt
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:

Decision Framework
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 ViewModel

This decision table summarizes it:

ScenarioRecommended Approach
Network-only, simple APIDirect API calls in ViewModel
Network-only, complex APIUseCase + API
Local DB onlyRoom DAO (DAO IS your repository)
Local + Remote with cachingRepository Pattern
Offline-first appRepository + Sync logic
Multiple data sourcesRepository

Clean Architecture context

Repository fits into the Clean Architecture layer structure:

Clean Architecture Layers
UI Layer (Compose/XML)
ViewModels
UseCases (optional but recommended)
Repository (conditional - see flowchart)
Data Sources (Retrofit API, Room DAO, etc.)

Key principles:

  1. Dependency Rule: Inner layers don’t know about outer layers
  2. Repository is a Contract: It defines WHAT data you need, not WHERE it comes from
  3. UseCases for Business Logic: Don’t put business rules in Repository

Clean Architecture becomes over-engineering when you add unnecessary layers:

Over-engineering vs Appropriate
OVER-ENGINEERED:
ViewModel → UseCase → Repository → RemoteDataSource → RetrofitAPI
APPROPRIATE:
ViewModel → Repository → (Room + Retrofit)

Common mistakes to avoid

Mistake 1: Empty repositories

BadRepository.kt
// BAD: Repository that just delegates - adds no value
class 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

BusinessLogicMistake.kt
// BAD: Business logic doesn't belong in Repository
class 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

Over-abstraction vs Simple approach
BAD: Too many layers
Repository → RemoteDataSource → ApiWrapper → RetrofitService
GOOD: Direct and simple
Repository → (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