Your ViewModel is not your dumping ground.
Don’t Make Your ViewModels Do Everything — There’s a Better Way
Overloaded ViewModels are a code smell. Discover a cleaner, scalable architecture for Android apps — and stop treating your ViewModel like a god object.
Not a Medium Member? “Read for Free”
Let me guess — you start a new feature, create a ViewModel, and before you know it, it’s handling:
- UI state
- Business logic
- Navigation
- Validation
- Repository calls
- Event tracking
- Even debounce logic for text inputs?
You’re not alone.
In Android development, the ViewModel often becomes a kitchen sink—responsible for everything because, well, it’s there.
But here’s the problem: bloated ViewModels make your code harder to test, harder to maintain, and harder to scale.
In this article, I’ll show you why this happens, what to do instead, and how a few architectural shifts can give your ViewModels a healthy, focused purpose — without doing all the heavy lifting.
At first, your ViewModel looks innocent:
@HiltViewModel
class MenuViewModel @Inject constructor(
private val repository: MenuRepository,
private val localPreferenceStore: LocalPreferenceStore
) : ViewModel() {
private val _menusEventFlow: MutableStateFlow<ApiUiState<MenuResponse>> = MutableStateFlow(
ApiUiState()
)
var menuEventFlow = _menusEventFlow.asStateFlow()
private set
fun onEvent(
events: MenuEvents
) = viewModelScope.launch {
when (events) {
MenuEvents.GetMenuEvent -> {
repository.getMenus()
.doOnLoading {
_menusEventFlow.value = ApiUiState(
isLoading = true
)
}
.doOnFailure {
_menusEventFlow.value = ApiUiState(
error = it ?: "SOMETHING WENT WRONG!!"
)
}
.doOnSuccess {
_menusEventFlow.value = ApiUiState(
data = it
)
}
.collect()
}
}
}
}But fast forward a month — and suddenly your ViewModel:
- Tracks input state
- Validates fields
- Formats UI errors
- Handles loading states
- Deals with exceptions
- Maps repository results to UI states
- Navigates between screens
Sound familiar?
Why This Happens
- The ViewModel is Convenient
It’s the default “home” for anything UI-related. Since it survives configuration changes and provides coroutine support, we keep throwing things into it. - Lack of Layered Thinking
Many teams skip over architectural layers like use cases or interactors. As a result, business logic and state management get lumped together. - Premature Optimization for Simplicity
It’s easier to keep everything in one place — until it’s not. What starts as convenience quickly turns into complexity.
Let’s break up the responsibilities the right way.
1. UseCases (aka Interactors) for Business Logic
Encapsulate app-specific logic in use cases. These should be platform-agnostic, testable, and focused on a single action.
class LoginUseCase(private val repo: AuthRepository) {
suspend operator fun invoke(email: String, password: String): Result<User> {
if (!isValidEmail(email)) return Result.failure(IllegalArgumentException("Invalid email"))
return repo.login(email, password)
}
}ViewModel becomes a delegator (tell others to do this work):
fun login() {
viewModelScope.launch {
val result = loginUseCase(email, password)
// Update UI state based on result
}
}2. Mappers for UI Formatting
Map domain models to UI-friendly models in a dedicated mapper class.
class UserUiMapper {
fun map(user: User): UserUi {
return UserUi(name = user.fullName.uppercase())
}
}Keeps formatting out of your ViewModel and centralizes presentation logic.
3. Navigation? Use Events or a Navigator
Navigation doesn’t belong in the ViewModel directly. Instead, use an Event or Navigator abstraction that the UI observes.
sealed class UiEvent {
object NavigateToHome : UiEvent()
data class ShowSnackbar(val message: String) : UiEvent()
}Send events through a SharedFlow or similar mechanism.
What Your ViewModel Should Do
- Hold and expose UI state
- Coordinate async actions
- Emit UI events
- Delegate logic to other layers
That’s it.
Keep it lean, predictable, and testable.
Let’s Refactoring a Messy ViewModel
Before:
// Handles input, validation, repository calls, and UI state
class SignUpViewModel : ViewModel() {
// 200+ lines of mixed responsibilities
}After:
SignUpViewModel: 60 lines, delegates all logicSignUpUseCase: Handles rules and repoSignUpValidator: Separate validator classUiEvent: Centralized navigation/snackbar events
Result:
Cleaner code, better test coverage, easier onboarding for new devs.
Conclusion
Your ViewModel shouldn’t be a god class.
Think of it as a smart presenter, not a logic warehouse.
By applying clean architecture principles — like separation of concerns and layered responsibilities — you’ll write more maintainable, testable, and scalable Android code.
Start small — extract one responsibility today. Your future self (and teammates) will thank you.
