← Back to list

How Your “Clean” ViewModel Quietly Becomes a God ViewModel

You followed the docs. One screen, one ViewModel. So why is it 900 lines now?

Himanshugaur · 2026-06-01 06:07 · 1 claps · 6.9 min read
#android #android-app-development #kotlin #androiddev #android-apps
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🔧 · Data Engineering

How Your “Clean” ViewModel Quietly Becomes a God ViewModel

You followed the docs. One screen, one ViewModel. So why is it 900 lines now?

Nobody writes a god ViewModel on purpose.

You start clean. A single ViewModel for a single screen just like the Android docs recommend. It has a UI state, a couple of functions, maybe a repository call. You feel good about it.

Then the product requirements keep coming. And six sprints later, you’re staring at a 900-line ViewModel that knows everything about everything and you have no idea how you got here.

Let me show you exactly how it happens.

Sprint 1: The Innocent Beginning

You’re building a user profile screen. Simple enough.

class ProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState = _uiState.asStateFlow()

    init {
        loadProfile()
    }

    private fun loadProfile() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            val user = userRepository.getUser()
            _uiState.update { it.copy(isLoading = false, user = user) }
        }
    }
}

Clean. Focused. About 20 lines. You’d show this in a code review and get a thumbs-up.

Sprint 2: “We Need Edit Functionality”

Product says users should be able to edit their name and bio on the same screen. No problem it’s still the profile screen, right?

class ProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState = _uiState.asStateFlow()

    init {
        loadProfile()
    }

    private fun loadProfile() { /* ... */ }

    fun onNameChanged(name: String) {
        _uiState.update { it.copy(editName = name) }
    }

    fun onBioChanged(bio: String) {
        _uiState.update { it.copy(editBio = bio) }
    }

    fun saveProfile() {
        viewModelScope.launch {
            _uiState.update { it.copy(isSaving = true) }
            userRepository.updateUser(
                name = _uiState.value.editName,
                bio = _uiState.value.editBio
            )
            _uiState.update { it.copy(isSaving = false, isEditing = false) }
        }
    }

    fun toggleEditMode() {
        _uiState.update { it.copy(isEditing = !it.isEditing) }
    }
}

Still looks reasonable. You added form handling, but it makes sense it’s the same screen.

This is the moment it begins. You just didn’t know it yet.

Sprint 3: “Add Validation”

Of course the name can’t be empty. And the bio has a character limit. And the name can’t contain special characters.

fun onNameChanged(name: String) {
    val nameError = when {
        name.isBlank() -> "Name cannot be empty"
        name.length > 50 -> "Name is too long"
        !name.matches(Regex("^[a-zA-Z ]+$")) -> "Name contains invalid characters"
        else -> null
    }
    _uiState.update { it.copy(editName = name, nameError = nameError) }
}

fun onBioChanged(bio: String) {
    val bioError = when {
        bio.length > 300 -> "Bio must be under 300 characters"
        else -> null
    }
    _uiState.update { it.copy(editBio = bio, bioError = bioError) }
}

private fun isFormValid(): Boolean {
    val state = _uiState.value
    return state.nameError == null && state.bioError == null
}

You’re at ~80 lines now. Still manageable. Still “just the profile screen.”

Sprint 4: “Add Profile Picture Upload”

Users want to change their avatar. It’s on the profile screen, so…

fun onProfileImageSelected(uri: Uri) {
    viewModelScope.launch {
        _uiState.update { it.copy(isUploadingImage = true) }
        try {
            val compressedImage = imageCompressor.compress(uri)
            val imageUrl = imageRepository.uploadImage(compressedImage)
            _uiState.update { 
                it.copy(
                    isUploadingImage = false,
                    profileImageUrl = imageUrl
                )
            }
        } catch (e: Exception) {
            _uiState.update { 
                it.copy(
                    isUploadingImage = false,
                    imageUploadError = "Failed to upload image"
                )
            }
        }
    }
}

fun onCameraPermissionResult(granted: Boolean) {
    if (granted) {
        _uiState.update { it.copy(showCamera = true) }
    } else {
        _uiState.update { it.copy(showPermissionRationale = true) }
    }
}

fun dismissPermissionRationale() {
    _uiState.update { it.copy(showPermissionRationale = false) }
}

Now you have image compression, upload, permission handling. The constructor grows:

class ProfileViewModel(
    private val userRepository: UserRepository,
    private val imageRepository: ImageRepository,
    private val imageCompressor: ImageCompressor
) : ViewModel()

Sprint 5: “Show User’s Posts on Their Profile”

Like Instagram. The profile screen now shows a grid of posts.

private val _posts = MutableStateFlow<List<Post>>(emptyList())

fun loadPosts() {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoadingPosts = true) }
        val posts = postRepository.getPostsByUser(userId)
        _posts.value = posts
        _uiState.update { it.copy(isLoadingPosts = false) }
    }
}

fun loadMorePosts() {
    viewModelScope.launch {
        val nextPage = currentPage + 1
        val morePosts = postRepository.getPostsByUser(userId, page = nextPage)
        _posts.value = _posts.value + morePosts
        currentPage = nextPage
    }
}

fun deletePost(postId: String) {
    viewModelScope.launch {
        postRepository.deletePost(postId)
        _posts.value = _posts.value.filter { it.id != postId }
    }
}

fun onPostLiked(postId: String) { /* ... */ }
fun onPostBookmarked(postId: String) { /* ... */ }
fun onPostShared(postId: String) { /* ... */ }

Constructor:

class ProfileViewModel(
    private val userRepository: UserRepository,
    private val imageRepository: ImageRepository,
    private val imageCompressor: ImageCompressor,
    private val postRepository: PostRepository,
    private val analyticsTracker: AnalyticsTracker
) : ViewModel()

Sprint 6: “Add Follow/Unfollow and Analytics”

fun toggleFollow() {
    viewModelScope.launch {
        val isFollowing = _uiState.value.isFollowing
        if (isFollowing) {
            followRepository.unfollow(userId)
            analyticsTracker.track("user_unfollowed")
        } else {
            followRepository.follow(userId)
            analyticsTracker.track("user_followed")
        }
        _uiState.update { it.copy(isFollowing = !isFollowing) }
    }
}

fun onScreenViewed() {
    analyticsTracker.track("profile_viewed", mapOf("userId" to userId))
}

fun onEditStarted() {
    analyticsTracker.track("profile_edit_started")
}

fun onPostTapped(postId: String) {
    analyticsTracker.track("post_tapped", mapOf("postId" to postId))
}

Step Back. Look at What You Built.

Let’s count what this “single screen ViewModel” now handles:

  • ✅ Loading user profile
  • ✅ Edit mode toggling
  • ✅ Form state management (name, bio)
  • ✅ Input validation (multiple fields, multiple rules)
  • ✅ Profile image selection, compression, and upload
  • ✅ Camera permission handling
  • ✅ Permission rationale dialog
  • ✅ Posts list loading
  • ✅ Pagination
  • ✅ Post CRUD operations (delete, like, bookmark, share)
  • ✅ Follow/unfollow logic
  • ✅ Analytics tracking (6+ events)
  • ✅ Multiple error states
  • ✅ Multiple loading states

14 responsibilities. In one class.

The constructor has 6 dependencies. The UiState data class has 15+ fields. The ViewModel is pushing 400 lines and I skipped error handling for half of it.

And here’s the worst part: at no single sprint did it feel wrong.

Each addition was small. Each one was “part of the profile screen.” Each code review passed because the diff was just +30 lines.

A god ViewModel doesn’t happen in one commit. It happens in twenty reasonable ones.

The Symptoms You’re Ignoring

Your ViewModel might already be a god ViewModel if:

  1. Your UiState data class has more than 8–10 fields. If you need to scroll to see all of them, that’s a signal.
  2. Your constructor has more than 3–4 dependencies. Each dependency is a separate concern your ViewModel is managing.
  3. You use the phrase “it’s still the same screen” to justify adding logic. The screen is not the boundary for responsibility. The behavior is.
  4. Different parts of the ViewModel never interact with each other. If toggleFollow() and onBioChanged() share no state, they probably don't belong together.
  5. Your ViewModel survives a rewrite of half the screen. If you could delete the posts section and 40% of the ViewModel becomes dead code those were separate concerns glued together.

Why “One Screen = One ViewModel” Is a Misunderstood Rule

The Android docs show one ViewModel per screen because it’s simple to teach. But it was never meant to be a rule about responsibility.

A screen is a UI boundary. A ViewModel should be a behavior boundary.

A profile screen doesn’t have one behavior. It has:

  • Profile viewing (load + display user data)
  • Profile editing (form state + validation + save)
  • Media management (image pick + compress + upload + permissions)
  • Post feed (load + paginate + interact)
  • Social actions (follow/unfollow)

These are five behaviors that happen to live on one screen.

The Fix: Behavior-Scoped ViewModels

You don’t need a new architecture. You don’t need MVI or Orbit or some library. You just need to split by behavior.

// Each ViewModel owns ONE behavior
class ProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
    // Load and display profile. That's it.
}

class ProfileEditViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
    // Form state, validation, save
}

class ProfileImageViewModel(
    private val imageRepository: ImageRepository,
    private val imageCompressor: ImageCompressor
) : ViewModel() {
    // Pick, compress, upload, permission handling
}

class UserPostsViewModel(
    private val postRepository: PostRepository
) : ViewModel() {
    // Load, paginate, delete, like, bookmark
}

class FollowViewModel(
    private val followRepository: FollowRepository
) : ViewModel() {
    // Follow/unfollow
}

Each ViewModel:

  • Has 1–2 dependencies
  • Has a small, focused UiState
  • Is testable in isolation
  • Can be reused on other screens (imagine FollowViewModel on a search results screen)

In your Composable:

@Composable
fun ProfileScreen(
    profileViewModel: ProfileViewModel = hiltViewModel(),
    editViewModel: ProfileEditViewModel = hiltViewModel(),
    imageViewModel: ProfileImageViewModel = hiltViewModel(),
    postsViewModel: UserPostsViewModel = hiltViewModel(),
    followViewModel: FollowViewModel = hiltViewModel()
) {
    val profileState by profileViewModel.uiState.collectAsStateWithLifecycle()
    val editState by editViewModel.uiState.collectAsStateWithLifecycle()
    // ...
}

“But that’s so many ViewModels for one screen!”

The Litmus Test

Next time you’re about to add a function to a ViewModel, ask yourself:

“If I deleted this function and everything it touches, would the rest of the ViewModel still make complete sense?”

If yes, that function belongs in a different ViewModel.

Your ViewModel isn’t your screen. It’s a behavior. Name it after the behavior, scope it to the behavior, and let go of the “one screen, one ViewModel” comfort zone.

Your future self debugging a 900-line ProfileViewModel at 2 AM will thank you.

If this resonated with you, you’ve probably already done this. No judgment I’ve done it too. The point isn’t to feel bad about the past. It’s to notice the pattern before the next ViewModel crosses the line.

👋 Let’s Connect

If you enjoy content about Android development, Jetpack Compose, Kotlin, software architecture, and engineering best practices, I’d love to stay connected.

📺 **YouTube** In-depth Android tutorials, real-world projects, architecture discussions, and practical development tips.

💼 **LinkedIn** Professional updates, technical insights, articles, and lessons from my software engineering journey.

✍️ Medium More deep dives into Android development, clean architecture, testing, performance optimization, and modern engineering practices.

📚 Want to Go Deeper?

If you’re looking for structured, comprehensive learning resources, check out my Android development books, where I cover concepts in much greater depth than a typical article.

Support My Work

Creating free technical content, books, tutorials, and open educational resources takes considerable time and effort. If this article helped you learn something valuable, you can support my work by:

• Purchasing one of my books • Sharing my articles with others • Buying me a coffee

Every bit of support helps me continue creating high-quality content for the Android community.

Click here to support

SOLID Principles for Android Developers — Learn how to write maintainable, scalable, and testable Android applications using SOLID principles with real-world examples.

👉 Explore all my books: Book Link


메타데이터
post_id
5bfaff4f95d2
slug
how-your-clean-viewmodel-quietly-becomes-a-god-viewmodel-5bfaff4f95d2
url
https://medium.com/@himanshugaur684/how-your-clean-viewmodel-quietly-becomes-a-god-viewmodel-5bfaff4f95d2
canonical_url
https://medium.com/@himanshugaur684/how-your-clean-viewmodel-quietly-becomes-a-god-viewmodel-5bfaff4f95d2
author_url
https://medium.com/@himanshugaur684
status
ok
fetched_at
2026-06-09 15:37:30