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.
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.
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
}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.
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
- Use MVI for complex screens — the contract pattern makes behavior explicit and testable
- Mark data classes as
@Immutable— it's the single highest-impact performance optimization - Modularize by feature — it scales with team size and enforces clean boundaries
- 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.