← Back to Blog

Building Scalable Android Apps with Jetpack Compose

A deep dive into production Compose architecture — from state management patterns to performance optimization techniques that survive real-world conditions.

AG Mobile Labs8 min read
AndroidJetpack ComposeArchitectureKotlin

Building Scalable Android Apps with Jetpack Compose

Jetpack Compose has fundamentally changed how we build Android UIs. But moving from "it works in a demo" to "it scales in production" requires deliberate architectural decisions that most tutorials skip entirely.

This article covers the patterns I use in production Compose applications — the ones that actually matter when your app has 50K+ daily active users and a team of engineers working on it simultaneously.

The Architecture Problem

Most Compose tutorials show you how to build a counter app or a todo list. The architecture patterns they demonstrate — hoisting state into a parent composable, using remember everywhere — simply don't scale.

🔴Important

If your entire screen's state lives in a single composable function with 15 remember calls, you have a maintenance problem waiting to happen.

In production, you need:

  • Predictable state management that survives configuration changes
  • Testable business logic that doesn't depend on the UI framework
  • Modular screen composition that lets multiple engineers work on the same feature

State Management: MVI Over MVVM

I've shipped Compose apps with both MVVM and MVI patterns. MVI wins for complex screens, and here's why.

The MVI Contract

Every screen gets a contract that defines exactly three things:

// Screen contract — the single source of truth
data class MediaPlayerState(
    val isPlaying: Boolean = false,
    val currentTrack: Track? = null,
    val queue: List<Track> = emptyList(),
    val playbackPosition: Long = 0L,
    val isBuffering: Boolean = false,
    val error: PlayerError? = null,
)
 
sealed interface MediaPlayerIntent {
    data object Play : MediaPlayerIntent
    data object Pause : MediaPlayerIntent
    data class SeekTo(val position: Long) : MediaPlayerIntent
    data class SelectTrack(val track: Track) : MediaPlayerIntent
    data object SkipNext : MediaPlayerIntent
}
 
sealed interface MediaPlayerEffect {
    data class ShowError(val message: String) : MediaPlayerEffect
    data object NavigateToQueue : MediaPlayerEffect
}

This is not boilerplate — it's a specification. Any engineer can look at this contract and understand exactly what the screen does, what actions are available, and what side effects can occur.

The ViewModel

class MediaPlayerViewModel(
    private val playerRepository: PlayerRepository,
    private val analyticsTracker: AnalyticsTracker,
) : ViewModel() {
 
    private val _state = MutableStateFlow(MediaPlayerState())
    val state: StateFlow<MediaPlayerState> = _state.asStateFlow()
 
    private val _effects = Channel<MediaPlayerEffect>()
    val effects: Flow<MediaPlayerEffect> = _effects.receiveAsFlow()
 
    fun handleIntent(intent: MediaPlayerIntent) {
        when (intent) {
            is MediaPlayerIntent.Play -> play()
            is MediaPlayerIntent.Pause -> pause()
            is MediaPlayerIntent.SeekTo -> seekTo(intent.position)
            is MediaPlayerIntent.SelectTrack -> selectTrack(intent.track)
            is MediaPlayerIntent.SkipNext -> skipNext()
        }
    }
 
    private fun play() {
        viewModelScope.launch {
            _state.update { it.copy(isPlaying = true) }
            playerRepository.play()
            analyticsTracker.trackPlayback("play")
        }
    }
 
    // ... other handlers
}
ℹ️Info

The key insight: every state change goes through handleIntent(). This makes the entire screen's behavior auditable, testable, and debuggable. You can literally replay a sequence of intents to reproduce any bug.

Performance: Composition Stability

The number one performance issue in production Compose apps is unnecessary recompositions. And the fix is almost always the same: make your data classes stable.

The Problem

// This causes recomposition on EVERY frame during a list scroll
@Composable
fun TrackList(tracks: List<Track>) {
    LazyColumn {
        items(tracks) { track ->
            TrackRow(track = track)
        }
    }
}

If Track is an unstable class (contains mutable fields, or fields the Compose compiler can't verify as stable), every TrackRow recomposes on every frame.

The Fix

@Immutable
data class Track(
    val id: String,
    val title: String,
    val artist: String,
    val durationMs: Long,
    val artworkUrl: String,
)

Adding @Immutable tells the Compose compiler this class will never change after construction. Combined with key in LazyColumn:

LazyColumn {
    items(tracks, key = { it.id }) { track ->
        TrackRow(track = track)
    }
}

This alone eliminated 60% of unnecessary recompositions in one production app I worked on.

Modularization Strategy

For teams larger than 2-3 engineers, feature-based modularization is essential:

:app                    ← Thin shell, navigation, DI setup
:feature:player         ← Media player feature
:feature:library        ← Music library feature
:feature:search         ← Search feature
:core:ui                ← Shared Compose components
:core:data              ← Repositories, data sources
:core:domain            ← Business logic, use cases
:core:network           ← API clients
:core:database          ← Room DAOs, entities

Each :feature:* module owns its own screens, ViewModels, and navigation. It depends on :core:* modules but never on other features.

⚠️Warning

Avoid the temptation to create a :common module that becomes a dumping ground. If something is shared between exactly two features, it probably belongs in :core:domain as a use case, not in a shared utilities module.

Key Takeaways

  1. Use MVI for complex screens — the contract pattern makes behavior explicit and testable
  2. Mark data classes as @Immutable — it's the single highest-impact performance optimization
  3. Modularize by feature — it scales with team size and enforces clean boundaries
  4. Test the ViewModel, not the UI — Compose Preview + MVI contracts give you confidence without brittle UI tests

These patterns aren't theoretical — they're running in production apps serving tens of thousands of users daily. The architecture decisions you make in the first month of a project determine your velocity for the next two years.


Building a Compose app and need architecture guidance? Get in touch — I help teams design systems that scale.

Enjoyed this article?

I write about Android architecture, Kotlin, and mobile systems engineering. Let's connect.