Why ViewModel Composition Matters

Over my 8+ years building Android apps, I've seen teams struggle with scaling MVVM architecture as projects grow. The problem isn't MVVM itself—it's how we structure Android ViewModel dependencies and state management. When I led the migration at CodeBrew Labs that reduced our crash rate by 35%, one of the biggest wins came from rethinking how we composed ViewModels instead of inheriting them.

Today, most junior and mid-level Android engineers inherit from a base ViewModel class. It feels clean at first. But as your codebase scales, you end up with deep inheritance chains, conflicting state logic, and testing nightmares. I've watched production apps crash because a parent ViewModel's lifecycle method was overridden incorrectly three layers down.

This post shares the exact pattern I now use across all my projects at Raybit Technologies and as a freelancer. It's practical, battle-tested, and immediately applicable to your codebase.

The Inheritance Problem in MVVM

The Classic Base ViewModel Anti-Pattern

Let me walk you through a real scenario. You start with a base ViewModel to handle common state:

abstract class BaseViewModel : ViewModel() {
    protected val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    
    protected val _errorMessage = MutableStateFlow<String?>(null)
    val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
    
    protected fun showError(message: String) {
        _errorMessage.value = message
    }
}

class UserProfileViewModel(private val userRepo: UserRepository) : BaseViewModel() {
    private val _userState = MutableStateFlow<User?>(null)
    val userState: StateFlow<User?> = _userState.asStateFlow()
    
    fun loadUser(id: String) {
        _isLoading.value = true
        // load user...
    }
}

This looks reasonable. But what happens when you need a ViewModel that manages network pagination AND local caching AND analytics? You create another base class. Then another for offline-first behavior. Soon you have inheritance diamonds, mixed concerns, and state mutations scattered across multiple files.

Why Inheritance Breaks at Scale

  • Single Responsibility Violation: A base ViewModel tries to handle loading states, errors, AND analytics. That's three separate concerns.
  • Rigid Hierarchy: You can't mix behaviors from different branches. What if you need pagination AND analytics but NOT the error handling from your base class?
  • Testing Hell: You're forced to mock the entire parent class hierarchy. A simple unit test becomes a maze of setUp() chains.
  • Hidden Dependencies: A parent ViewModel might depend on a lifecycle callback that a child overrides, breaking the contract silently.

⚠️ Real Cost

At CodeBrew Labs, we had a crash where a parent ViewModel's onCleared() was trying to cancel Jobs created by a child class. The child had already nulled out the reference. Took us 3 hours to debug. That's when I decided to refactor toward composition.

Composition Over Inheritance Approach

The Composition Philosophy

Instead of a deep inheritance tree, I now use Android architecture built on composable concerns. Each responsibility is a separate interface or holder class. Your ViewModel composes these pieces, keeping it focused and testable.

Here's the shift in thinking:

"Instead of asking 'What's my base class?', ask 'What behaviors do I need?'" — Me, after 6+ years of Android battles.

Defining Behavior Holders

Let's create composable state managers instead of base classes:

// 1. Loading State Handler
interface LoadingStateHolder {
    val isLoading: StateFlow<Boolean>
    fun setLoading(loading: Boolean)
}

class LoadingStateHolderImpl : LoadingStateHolder {
    private val _isLoading = MutableStateFlow(false)
    override val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    override fun setLoading(loading: Boolean) { _isLoading.value = loading }
}

// 2. Error State Handler
interface ErrorStateHolder {
    val errorMessage: StateFlow<String?>
    fun showError(message: String)
    fun clearError()
}

class ErrorStateHolderImpl : ErrorStateHolder {
    private val _errorMessage = MutableStateFlow<String?>(null)
    override val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
    override fun showError(message: String) { _errorMessage.value = message }
    override fun clearError() { _errorMessage.value = null }
}

// 3. Pagination Handler
interface PaginationHolder {
    val currentPage: StateFlow<Int>
    fun nextPage()
    fun resetPagination()
}

class PaginationHolderImpl : PaginationHolder {
    private val _currentPage = MutableStateFlow(1)
    override val currentPage: StateFlow<Int> = _currentPage.asStateFlow()
    override fun nextPage() { _currentPage.value++ }
    override fun resetPagination() { _currentPage.value = 1 }
}

Practical Implementation with Code

Building a Scalable ViewModel with Composition

Now here's the magic: your ViewModel composes exactly the behaviors it needs, nothing more:

class UserListViewModel(
    private val userRepository: UserRepository,
    private val loadingStateHolder: LoadingStateHolder = LoadingStateHolderImpl(),
    private val errorStateHolder: ErrorStateHolder = ErrorStateHolderImpl(),
    private val paginationHolder: PaginationHolder = PaginationHolderImpl()
) : ViewModel(),
    LoadingStateHolder by loadingStateHolder,
    ErrorStateHolder by errorStateHolder,
    PaginationHolder by paginationHolder {
    
    private val _userList = MutableStateFlow<List<User>>(emptyList())
    val userList: StateFlow<List<User>> = _userList.asStateFlow()
    
    init {
        loadUsers()
    }
    
    fun loadUsers() {
        viewModelScope.launch {
            setLoading(true)
            clearError()
            try {
                val users = userRepository.fetchUsers(currentPage.value)
                _userList.value = if (currentPage.value == 1) {
                    users
                } else {
                    _userList.value + users
                }
            } catch (e: Exception) {
                showError(e.message ?: "Failed to load users")
            } finally {
                setLoading(false)
            }
        }
    }
    
    fun loadNextPage() {
        nextPage()
        loadUsers()
    }
}

Why This Works

  • No Inheritance Chain: The ViewModel uses delegation to compose behavior. If you don't need pagination later, remove it from the constructor.
  • Testable: You can mock each holder independently. No deep inheritance mock chains.
  • Flexible: Need analytics? Add an AnalyticsHolder. Your ViewModel doesn't change.
  • Reusable: LoadingStateHolder works in any ViewModel, any feature, any architecture.

📖 Kotlin Delegation Magic

The by keyword in Kotlin lets you delegate interface methods to the implementation class. This avoids boilerplate: you don't manually forward setLoading(), showError(), etc. The compiler generates it.

Handling Complex State with Composition

What if you need a specialized holder for your specific feature? Just create one:

interface SearchHistoryHolder {
    val searchHistory: StateFlow<List<String>>
    fun addToHistory(query: String)
    fun clearHistory()
}

class SearchHistoryHolderImpl : SearchHistoryHolder {
    private val _history = MutableStateFlow<List<String>>(emptyList())
    override val searchHistory = _history.asStateFlow()
    
    override fun addToHistory(query: String) {
        _history.value = (listOf(query) + _history.value).take(10)
    }
    
    override fun clearHistory() {
        _history.value = emptyList()
    }
}

// Now compose it into your ViewModel
class SearchViewModel(
    private val userRepository: UserRepository,
    private val loadingStateHolder: LoadingStateHolder = LoadingStateHolderImpl(),
    private val searchHistoryHolder: SearchHistoryHolder = SearchHistoryHolderImpl()
) : ViewModel(),
    LoadingStateHolder by loadingStateHolder,
    SearchHistoryHolder by searchHistoryHolder {
    
    fun search(query: String) {
        addToHistory(query)
        // fetch results...
    }
}

Testing Composed ViewModels

Unit Testing Becomes Simple

Here's what your tests look like with this pattern:

class UserListViewModelTest {
    private val mockUserRepository = mockk<UserRepository>()
    private val mockLoadingHolder = mockk<LoadingStateHolder>(relaxed = true)
    private val mockErrorHolder = mockk<ErrorStateHolder>(relaxed = true)
    private val mockPaginationHolder = mockk<PaginationHolder>(relaxed = true)
    
    private lateinit var viewModel: UserListViewModel
    
    @Before
    fun setup() {
        viewModel = UserListViewModel(
            userRepository = mockUserRepository,
            loadingStateHolder = mockLoadingHolder,
            errorStateHolder = mockErrorHolder,
            paginationHolder = mockPaginationHolder
        )
    }
    
    @Test
    fun testLoadUsersSuccess() = runTest {
        // Arrange
        coEvery { mockUserRepository.fetchUsers(1) } returns listOf(
            User(1, "Alice"),
            User(2, "Bob")
        )
        every { mockLoadingHolder.setLoading(any()) } just Runs
        
        // Act
        viewModel.loadUsers()
        advanceUntilIdle()
        
        // Assert
        verify { mockLoadingHolder.setLoading(true) }
        verify { mockLoadingHolder.setLoading(false) }
        assertEquals(2, viewModel.userList.value.size)
    }
    
    @Test
    fun testLoadUsersError() = runTest {
        // Arrange
        coEvery { mockUserRepository.fetchUsers(1) } throws Exception("Network error")
        every { mockErrorHolder.showError(any()) } just Runs
        
        // Act
        viewModel.loadUsers()
        advanceUntilIdle()
        
        // Assert
        verify { mockErrorHolder.showError("Network error") }
    }
}

Why This Testing is Better

  • You mock only what you need. No forced mock chains.
  • Each holder is independently testable. You can test PaginationHolder in isolation.
  • Changes to one holder don't break unrelated tests.

📖 Practical Tip

I use relaxed = true on mock holders so I don't have to stub every single function. This keeps test code concise and focused on what matters.

Key Takeaways

  • Composition beats inheritance for scaling Android MVVM. Use composable state holders instead of deep base class hierarchies. Your codebase will remain flexible as requirements change.
  • Each holder has a single responsibility. LoadingStateHolder handles loading. ErrorStateHolder handles errors. This separation makes testing trivial and reuse natural.
  • Kotlin delegation eliminates boilerplate. The by keyword means you don't manually forward method calls. You get composition with minimal code.
  • Testing becomes straightforward. Mock individual holders instead of entire inheritance chains. Tests are faster, more readable, and less brittle.
  • Start applying this today: Refactor your base ViewModels into composable holders. Your future self—and your team—will thank you when the app scales to 100K+ lines of code.