Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Breaking Up with Big ViewModels — Without Break...

Breaking Up with Big ViewModels — Without Breaking Your Architecture (droidcon Berlin 2025)

In the past, Android developers struggled with bloated Activities and Fragments. Then MVVM, MVI, and Jetpack Compose helped us push presentation logic into ViewModels, business logic into UseCases, and data handling into Repositories — making the UI layer thin and reactive.

But a new problem has quietly emerged: we still tie ViewModels to entire screens, exposing the whole screen state and handling all user intents. As screens grow more complex, ViewModels become longer, harder to test, and harder to maintain.

What if we had multiple ViewModels per screen, each tied to a smaller UI component? This leads to two important questions that haven't been answered yet:

- Where do you hold the common state that multiple UI components of a screen might need?
- How can ViewModels communicate with each other when one needs to update the UI state that another ViewModel observes?

Repositories aren’t the answer — they expose domain models and shouldn’t know anything about the UI. Other state holder patterns that have been proposed do not fully address this problem either — they typically manage global or domain state, and they aren't designed to coordinate shared, screen-specific UI state across multiple ViewModels. Clearly, there’s a missing piece in our architecture.

Introducing the Mediator Pattern — a pattern that allows you to break a large ViewModel into smaller ones by moving the screen-specific UI state into a Mediator, with each smaller ViewModel deriving and updating only the part it needs.

Join this session to see the pattern applied in a real-world Android app and learn how to:

- Break a large ViewModel into smaller, independently testable ViewModels, each tied to a smaller component of the screen.
- Enable the smaller, screen-scoped ViewModels to reactively communicate with each other through a shared Mediator.
- Use Hilt or Koin to scope the Mediator to the screen’s lifecycle, automatically disposing it when the user exits that screen.

Whether you’re working with Jetpack Compose or traditional Views, this talk will give you the missing piece your architecture needs — so you can grow your apps without growing complexity.

Avatar for Stelios Frantzeskakis

Stelios Frantzeskakis PRO

September 22, 2025
Tweet

More Decks by Stelios Frantzeskakis

Other Decks in Programming

Transcript

  1. Breaking Up with Big ViewModels Without Breaking Your Architecture Stelios

    Frantzeskakis • Perry Street Software droidcon Berlin 2025
  2. Hi, I’m Stelios Frantzeskakis Staff Engineer at Perry Street Software

    Publisher of SCRUFF & Jack’d, serving more than 40M members Working with Android since the release of Android Gingerbread Passionate about Architecture & Testing @SteliosFran
  3. The Mediator pattern Gang of Four, Design Patterns: Elements of

    Reusable Object-Oriented Software “An object that encapsulates how a set of other objects interact with each other.”
  4. class GridViewModel( private val getMediaUseCase: GetMediaUseCase, private val gridMediator: GridMediator,

    ) : ViewModel() { val state: StateFlow<GridState> = gridMediator.state init { viewModelScope.launch(Dispatchers.Main) { getMediaUseCase() .map { GridState(GridMediaUiModelMapper.fromMediaList(it)) }.collect { newState -> gridMediator.updateState(newState) } } } fun onMediaLongTap(mediaId: String) { gridMediator.toggleSelection(mediaId) } fun onMediaTap(mediaId: String) { // ... } }
  5. class GridViewModel( private val getMediaUseCase: GetMediaUseCase, private val gridMediator: GridMediator,

    ) : ViewModel() { val state: StateFlow<GridState> = gridMediator.state init { viewModelScope.launch(Dispatchers.Main) { getMediaUseCase() .map { GridState(GridMediaUiModelMapper.fromMediaList(it)) }.collect { newState -> gridMediator.updateState(newState) } } } fun onMediaLongTap(mediaId: String) { gridMediator.toggleSelection(mediaId) } fun onMediaTap(mediaId: String) { // ... } }
  6. class GridViewModel( private val getMediaUseCase: GetMediaUseCase, private val gridMediator: GridMediator,

    ) : ViewModel() { val state: StateFlow<GridState> = gridMediator.state init { viewModelScope.launch(Dispatchers.Main) { getMediaUseCase() .map { GridState(GridMediaUiModelMapper.fromMediaList(it)) }.collect { newState -> gridMediator.updateState(newState) } } } fun onMediaLongTap(mediaId: String) { gridMediator.toggleSelection(mediaId) } fun onMediaTap(mediaId: String) { // ... } }
  7. class GridMediator { private val _state = MutableStateFlow(GridState(emptyList())) val state:

    StateFlow<GridState> = _state.asStateFlow() fun updateState(newState: GridState) { _state.value = newState } fun toggleSelection(mediaId: String) { val currentState = _state.value val updatedMedia = currentState.media.map { media -> if (media.id == mediaId) { media.copy(selected = !media.selected) } else { media } } _state.value = currentState.copy(media = updatedMedia) } }
  8. class GridMediator { private val _state = MutableStateFlow(GridState(emptyList())) val state:

    StateFlow<GridState> = _state.asStateFlow() fun updateState(newState: GridState) { _state.value = newState } fun toggleSelection(mediaId: String) { val currentState = _state.value val updatedMedia = currentState.media.map { media -> if (media.id == mediaId) { media.copy(selected = !media.selected) } else { media } } _state.value = currentState.copy(media = updatedMedia) } }
  9. class TopBarViewModel( gridMediator: GridMediator, ) : ViewModel() { val selectedCount:

    Flow<Int> = gridMediator.state.map { state -> state.media.count { it.selected } } fun onSearchTap() { // ... } fun onCancelTap() { // ... } }
  10. class TopBarViewModel( gridMediator: GridMediator, ) : ViewModel() { val selectedCount:

    Flow<Int> = gridMediator.state.map { state -> state.media.count { it.selected } } fun onSearchTap() { // ... } fun onCancelTap() { // ... } }
  11. class BottomBarViewModel( gridMediator: GridMediator, ) : ViewModel() { val isVisible:

    Flow<Boolean> = gridMediator.state.map { state -> state.media.any { it.selected } } fun onCreateTap() { // ... } fun onShareTap() { // ... } fun onDeleteTap() { // ... } }
  12. class BottomBarViewModel( gridMediator: GridMediator, ) : ViewModel() { val isVisible:

    Flow<Boolean> = gridMediator.state.map { state -> state.media.any { it.selected } } fun onCreateTap() { // ... } fun onShareTap() { // ... } fun onDeleteTap() { // ... } }
  13. @Composable fun GridScreen(scope: Scope) { val gridViewModel: GridViewModel = koinViewModel(scope

    = scope) val gridState by gridViewModel.state.collectAsState() val topBarViewModel: TopBarViewModel = koinViewModel(scope = scope) val selectedCount by topBarViewModel.selectedCount.collectAsState(initial = 0) val bottomBarViewModel: BottomBarViewModel = koinViewModel(scope = scope) val isBottomBarVisible by bottomBarViewModel.isVisible.collectAsState(initial = false) AppTheme { Scaffold( topBar = { TopBar( selectedCount = selectedCount, onSearchTap = topBarViewModel::onSearchTap, onCancelTap = topBarViewModel::onCancelTap, ) }, bottomBar = { BottomBar( isVisible = isBottomBarVisible, onCreateTap = bottomBarViewModel::onCreateTap, onDeleteTap = bottomBarViewModel::onDeleteTap, ) }, ) { paddingValues -> Grid( state = gridState, modifier = Modifier.padding(paddingValues), onLongTap = gridViewModel::onMediaLongTap, ) } } }
  14. @Scope(name = Routes.GRID) @Scoped class GridMediator { private val _state

    = MutableStateFlow(GridState(emptyList())) val state: StateFlow<GridState> = _state.asStateFlow() // ... }
  15. @Composable fun GridScreen(scope: Scope) { val gridViewModel: GridViewModel = koinViewModel(scope

    = scope) val gridState by gridViewModel.state.collectAsState() val topBarViewModel: TopBarViewModel = koinViewModel(scope = scope) val selectedCount by topBarViewModel.selectedCount.collectAsState(initial = 0) val bottomBarViewModel: BottomBarViewModel = koinViewModel(scope = scope) val isBottomBarVisible by bottomBarViewModel.isVisible.collectAsState(initial = false) // ... }
  16. @Composable fun AppNavHost() { val navController = rememberNavController() NavHost( navController

    = navController, startDestination = Routes.GRID, ) { composable(Routes.GRID) { KoinScopedComposable(scopeName = Routes.GRID) { scope -> GridScreen(scope = scope) } } } }
  17. @Composable fun GridScreen(scope: Scope) { val gridViewModel: GridViewModel = hiltViewModel()

    val gridState by gridViewModel.state.collectAsState() val topBarViewModel: TopBarViewModel = hiltViewModel() val selectedCount by topBarViewModel.selectedCount.collectAsState(initial = 0) val bottomBarViewModel: BottomBarViewModel = hiltViewModel() val isBottomBarVisible by bottomBarViewModel.isVisible.collectAsState(initial = false) // ... }
  18. @HiltViewModel class GridViewModel @Inject constructor( private val getMediaUseCase: GetMediaUseCase, private

    val scopeHolder: ScopeHolder, @GridScopeId private val scopeId: String ) : ViewModel() { private val gridMediator by lazy { scopeHolder.getOrCreateMediator(scopeId) { GridMediator() } } // ... }
  19. Test the ViewModel, not the Mediator Martin Fowler, Refactoring: Improving

    the Design of Existing Code “Refactoring: A change made to the internal structure of software, without changing its observable behavior.”
  20. class GridViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() Then("I see a list of media") { with(gridViewModel.state.value) { media.size shouldBe 2 media[0].id shouldBeEqual "id1" media[0].title shouldBeEqual "Beautiful Landscape" media[1].id shouldBeEqual "id2" media[1].title shouldBeEqual "City View" } } } } }
  21. class GridViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() Then("I see a list of media") { with(gridViewModel.state.value) { media.size shouldBe 2 media[0].id shouldBeEqual "id1" media[0].title shouldBeEqual "Beautiful Landscape" media[1].id shouldBeEqual "id2" media[1].title shouldBeEqual "City View" } } } } }
  22. class GridViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() Then("I see a list of media") { with(gridViewModel.state.value) { media.size shouldBe 2 media[0].id shouldBeEqual "id1" media[0].title shouldBeEqual "Beautiful Landscape" media[1].id shouldBeEqual "id2" media[1].title shouldBeEqual "City View" } } } } }
  23. class TopBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val topBarViewModel: TopBarViewModel = gridScope.get() Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } When("I long tap on a media item") { gridViewModel.onMediaLongTap("id1") Then("The top bar shows 1 selected item") { topBarViewModel.selectedCount.first() shouldBe 1 } } } } }
  24. class TopBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val topBarViewModel: TopBarViewModel = gridScope.get() Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } When("I long tap on a media item") { gridViewModel.onMediaLongTap("id1") Then("The top bar shows 1 selected item") { topBarViewModel.selectedCount.first() shouldBe 1 } } } } }
  25. class TopBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val topBarViewModel: TopBarViewModel = gridScope.get() Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } When("I long tap on a media item") { gridViewModel.onMediaLongTap("id1") Then("The top bar shows 1 selected item") { topBarViewModel.selectedCount.first() shouldBe 1 } } } } }
  26. class TopBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val topBarViewModel: TopBarViewModel = gridScope.get() Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } When("I long tap on a media item") { gridViewModel.onMediaLongTap("id1") Then("The top bar shows 1 selected item") { topBarViewModel.selectedCount.first() shouldBe 1 } } } } }
  27. class TopBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val topBarViewModel: TopBarViewModel = gridScope.get() Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } When("I long tap on a media item") { gridViewModel.onMediaLongTap("id1") Then("The top bar shows 1 selected item") { topBarViewModel.selectedCount.first() shouldBe 1 } } } } }
  28. // ... And("I long tap on another media item") {

    gridViewModel.onMediaLongTap("id2") Then("The top bar shows 2 selected items") { topBarViewModel.selectedCount.first() shouldBe 2 } And("I long tap again on the same media items") { gridViewModel.onMediaLongTap("id1") gridViewModel.onMediaLongTap("id2") Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } } }
  29. // ... And("I long tap on another media item") {

    gridViewModel.onMediaLongTap("id2") Then("The top bar shows 2 selected items") { topBarViewModel.selectedCount.first() shouldBe 2 } And("I long tap again on the same media items") { gridViewModel.onMediaLongTap("id1") gridViewModel.onMediaLongTap("id2") Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } } }
  30. // ... And("I long tap on another media item") {

    gridViewModel.onMediaLongTap("id2") Then("The top bar shows 2 selected items") { topBarViewModel.selectedCount.first() shouldBe 2 } And("I long tap again on the same media items") { gridViewModel.onMediaLongTap("id1") gridViewModel.onMediaLongTap("id2") Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } } }
  31. // ... And("I long tap on another media item") {

    gridViewModel.onMediaLongTap("id2") Then("The top bar shows 2 selected items") { topBarViewModel.selectedCount.first() shouldBe 2 } And("I long tap again on the same media items") { gridViewModel.onMediaLongTap("id1") gridViewModel.onMediaLongTap("id2") Then("The top bar does not show any selected items”) { topBarViewModel.selectedCount.first() shouldBe 0 } } }
  32. class BottomBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val bottomBarViewModel: BottomBarViewModel = gridScope.get() Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } } }
  33. class BottomBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val bottomBarViewModel: BottomBarViewModel = gridScope.get() Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } } }
  34. class BottomBarViewModelTest : BaseBehaviorSpec() { init { Given("I open the

    grid screen") { val gridViewModel: GridViewModel = gridScope.get() val bottomBarViewModel: BottomBarViewModel = gridScope.get() Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } } }
  35. // ... When("I long tap on a media item") {

    gridViewModel.onMediaLongTap("id1") Then("The bottom bar is visible") { bottomBarViewModel.isVisible.first() shouldBe true } And("I long tap again on the same media item") { gridViewModel.onMediaLongTap("id1") Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } }
  36. // ... When("I long tap on a media item") {

    gridViewModel.onMediaLongTap("id1") Then("The bottom bar is visible") { bottomBarViewModel.isVisible.first() shouldBe true } And("I long tap again on the same media item") { gridViewModel.onMediaLongTap("id1") Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } }
  37. // ... When("I long tap on a media item") {

    gridViewModel.onMediaLongTap("id1") Then("The bottom bar is visible") { bottomBarViewModel.isVisible.first() shouldBe true } And("I long tap again on the same media item") { gridViewModel.onMediaLongTap("id1") Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } }
  38. // ... When("I long tap on a media item") {

    gridViewModel.onMediaLongTap("id1") Then("The bottom bar is visible") { bottomBarViewModel.isVisible.first() shouldBe true } And("I long tap again on the same media item") { gridViewModel.onMediaLongTap("id1") Then("The bottom bar is hidden") { bottomBarViewModel.isVisible.first() shouldBe false } } }
  39. class ViewModelLintRules : BaseBehaviorSpec() { init { Given("A ViewModel") {

    val viewModels = // ... Then("It exposes only one state”) { viewModels.assertTrue { // ... } } Then("It has maximum 5 dependencies") { viewModels.assertTrue { // ... } } } } }
  40. class MediatorLintRules : BaseBehaviorSpec() { init { Given("A mediator") {

    val mediators = // ... Then("It is injected only in ViewModels") { mediators.assertTrue { // ... } } } } }
  41. Resources Slide deck • https://speakerdeck.com/steliosf/breaking-up-with-big-viewmodels GitHub • https://github.com/perrystreetsoftware/breaking-up-with-big-viewmodels Put Your

    Tests on a Diet talk • https://www.steliosf.com/talks Start Enforcing with Lint Rules talk • https://www.steliosf.com/talks Konsist • https://github.com/LemonAppDev/konsist Harmonize • https://github.com/perrystreetsoftware/Harmonize Perry Street Engineering • https://medium.com/perry-street-software-engineering @SteliosFran • https://www.linkedin.com/in/SteliosFran
  42. Nine hundred lines in one single file Just scrolling through

    will take a while Break that ViewModel in smaller parts Trust the Mediator to hold their hearts