Sitemap

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.

4 min readJul 29, 2025

--

Press enter or click to view image in full size
Photo by Bjarne Vijfvinkel on Unsplash

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

  1. 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.
  2. 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.
  3. 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 logic
  • SignUpUseCase: Handles rules and repo
  • SignUpValidator: Separate validator class
  • UiEvent: 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.

--

--

Jayant Kumar🇮🇳
Jayant Kumar🇮🇳

Written by Jayant Kumar🇮🇳

Jayant Kumar is a Lead Software Engineer, passionate about building modern Android applications. He shares his expertise in Kotlin, Android, and Compose.

Responses (1)