The Problem: ViewModel Knows Too Much
When a ViewModel talks directly to Retrofit, Room, and SharedPreferences, it becomes impossible to test without running on a device. It also violates the Single Responsibility Principle — the ViewModel should manage UI state, not orchestrate data sources.
// Anti-pattern: ViewModel doing too much
class UserViewModel : ViewModel() {
private val api = RetrofitClient.userApi // knows Retrofit
private val db = AppDatabase.getInstance() // knows Room
private val prefs = SharedPreferences... // knows SharedPreferences
fun loadUser(id: String) {
viewModelScope.launch {
val cached = db.userDao().findById(id) // SQL knowledge
if (cached == null) {
val remote = api.getUser(id) // HTTP knowledge
db.userDao().insert(remote.toEntity()) // mapping knowledge
}
}
}
}
The Repository Pattern
A Repository is a single, abstract source of truth for a domain entity. It decides whether to fetch from network, cache, or database. The caller (ViewModel or UseCase) doesn't care.
// Interface defines the contract — ViewModel only knows this
interface UserRepository {
suspend fun getUser(id: String): Result<User>
fun observeUser(id: String): Flow<User?>
suspend fun refreshUser(id: String): Result<Unit>
}
// Concrete implementation knows all data sources
class UserRepositoryImpl @Inject constructor(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource,
private val networkMonitor: NetworkMonitor
) : UserRepository {
override suspend fun getUser(id: String): Result<User> {
// Cache-first strategy
val cached = localDataSource.getUser(id)
if (cached != null && !cached.isStale()) {
return Result.success(cached.toDomain())
}
return refreshUser(id).map { localDataSource.getUser(id)!!.toDomain() }
}
override fun observeUser(id: String): Flow<User?> =
localDataSource.observeUser(id) // Room Flow — always fresh from DB
.map { entity -> entity?.toDomain() }
override suspend fun refreshUser(id: String): Result<Unit> = runCatching {
if (!networkMonitor.isConnected()) throw NoNetworkException()
val remoteUser = remoteDataSource.fetchUser(id)
localDataSource.saveUser(remoteUser.toEntity())
}
}
Layer Mapping
Each layer uses its own data model. Mapping at the boundary keeps layers independent:
// Network model (Retrofit)
data class UserDto(
@SerializedName("user_id") val userId: String,
@SerializedName("full_name") val fullName: String,
@SerializedName("email_address") val emailAddress: String
)
// Database model (Room)
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
val email: String,
val cachedAt: Long
)
// Domain model (pure Kotlin — no framework dependencies)
data class User(
val id: String,
val name: String,
val email: String
)
// Extension functions for mapping
fun UserDto.toEntity() = UserEntity(
id = userId,
name = fullName,
email = emailAddress,
cachedAt = System.currentTimeMillis()
)
fun UserEntity.toDomain() = User(id = id, name = name, email = email)
fun UserEntity.isStale(): Boolean =
System.currentTimeMillis() - cachedAt > TimeUnit.MINUTES.toMillis(15)
The UseCase (Interactor) Pattern
A UseCase represents a single business operation. It composes one or more repositories and applies business rules. Each UseCase has one public method — typically invoke() — which lets you call it like a function.
// Use case wraps a single piece of business logic
class GetUserProfileUseCase @Inject constructor(
private val userRepository: UserRepository,
private val analyticsRepository: AnalyticsRepository
) {
suspend operator fun invoke(userId: String): Result<UserProfile> {
analyticsRepository.track(Event.ProfileViewed(userId))
return userRepository.getUser(userId).map { user ->
UserProfile(
user = user,
isCurrentUser = userRepository.getCurrentUserId() == userId
)
}
}
}
// Another use case: different operation, same repositories
class UpdateUserProfileUseCase @Inject constructor(
private val userRepository: UserRepository,
private val validationService: ProfileValidationService
) {
suspend operator fun invoke(update: ProfileUpdate): Result<Unit> {
val validationError = validationService.validate(update)
if (validationError != null) {
return Result.failure(ValidationException(validationError))
}
return userRepository.updateUser(update)
}
}
// ViewModel is now clean
@HiltViewModel
class UserProfileViewModel @Inject constructor(
private val getUserProfileUseCase: GetUserProfileUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userId = checkNotNull(savedStateHandle.get<String>("userId"))
private val _state = MutableStateFlow<UiState<UserProfile>>(UiState.Loading)
val state: StateFlow<UiState<UserProfile>> = _state.asStateFlow()
init {
viewModelScope.launch {
_state.value = getUserProfileUseCase(userId).fold(
onSuccess = { UiState.Success(it) },
onFailure = { UiState.Error(it.message ?: "Unknown error") }
)
}
}
}
When Use Cases Add Value vs Overengineering
Use cases earn their keep when:
- Business logic spans multiple repositories — e.g., "Post a comment" needs CommentRepository + NotificationRepository + AnalyticsRepository
- Complex validation or transformation before touching repositories
- Reuse across multiple ViewModels — same logic needed in two screens
- You write a business rule test — use cases are plain Kotlin, trivial to unit test
Skip use cases when:
// This use case adds nothing — it's a single repository call
class GetUserUseCase @Inject constructor(private val repo: UserRepository) {
suspend operator fun invoke(id: String) = repo.getUser(id)
}
// Just call repo.getUser(id) directly from the ViewModel
The rule of thumb: if the use case body is a single repository call with no transformation, it's ceremony without value.
Data Layer Boundaries
┌─────────────────────────────────────────┐
│ UI Layer │
│ Fragment / Activity / Compose │
└──────────────┬──────────────────────────┘
│ observes StateFlow
┌──────────────▼──────────────────────────┐
│ Domain Layer (optional) │
│ ViewModel UseCase Domain Models │
└──────────────┬──────────────────────────┘
│ calls interface
┌──────────────▼──────────────────────────┐
│ Data Layer │
│ Repository ← RemoteDS ← Retrofit │
│ ← LocalDS ← Room │
└─────────────────────────────────────────┘
Each boundary is crossed with a mapping and an interface. The inner layer (domain) never imports the outer layer (data).
Testing the Layers in Isolation
// Test repository without network
class UserRepositoryTest {
private val fakeRemote = FakeUserRemoteDataSource()
private val fakeLocal = FakeUserLocalDataSource()
private val fakeNetwork = FakeNetworkMonitor(isConnected = true)
private val repository = UserRepositoryImpl(fakeRemote, fakeLocal, fakeNetwork)
@Test
fun `getUser returns cached when fresh`() = runTest {
fakeLocal.saveUser(UserEntity(id = "1", name = "Alice", email = "a@b.com", cachedAt = System.currentTimeMillis()))
val result = repository.getUser("1")
assertTrue(result.isSuccess)
assertEquals("Alice", result.getOrThrow().name)
// Remote was NOT called
assertEquals(0, fakeRemote.fetchCount)
}
}
// Test use case without repository
class GetUserProfileUseCaseTest {
private val fakeRepo = FakeUserRepository()
private val fakeAnalytics = FakeAnalyticsRepository()
private val useCase = GetUserProfileUseCase(fakeRepo, fakeAnalytics)
@Test
fun `tracks analytics when profile viewed`() = runTest {
fakeRepo.setUser(User(id = "1", name = "Alice", email = "a@b.com"))
useCase("1")
assertTrue(fakeAnalytics.trackedEvents.any { it is Event.ProfileViewed })
}
}
Key Takeaways
| Concept | Summary |
|---|---|
| Repository | Abstract data source; decides cache vs network; single source of truth |
| Interface boundary | ViewModel/UseCase only sees interface, not implementation |
| Layer mapping | Each layer owns its model; map at the boundary |
| UseCase | Single business operation; composable; operator fun invoke() |
| When to skip UseCase | Single repository call with no business logic |
| Testability | Repositories and use cases are plain Kotlin — fast unit tests |