Android Architecture in 2025: Compose, MVVM, and Hilt
Android architecture 2025: Jetpack Compose + MVVM + Hilt is the recommended stack. Practitioner guide to migration from Fragments, pitfalls, and Kotlin.
I’ve been shipping Android apps since 2013, and I’ve watched the platform’s architecture mature through every loop: Activities with 3,000-line onCreate methods, Fragments juggling their own lifecycles, RxJava-everywhere, MVP, MVI, and now the calm after the storm. If you’re starting a new Android app in 2025, or modernizing an existing one, the recommended stack has converged. This post is the short version of the advice I give on most fractional CTO engagements.
The Current State of Android Architecture
For the last few years, Google has been quietly but firmly telling the community which patterns to use. The official guide to app architecture recommends a three-layer architecture — UI, domain (optional), and data — with a unidirectional data flow where state flows down and events flow up. That sounds abstract, but in practice it collapses to a very boring, very effective stack.
The reality most teams are now standardizing on:
- Kotlin — the only reasonable choice. If you still have Java, you are doing it wrong.
- Jetpack Compose — declarative UI, replacing XML layouts and the View system.
- MVVM with
ViewModel+StateFlow— Google’s pattern of choice, and genuinely the one with the least pain. - Hilt — Google’s opinionated wrapper over Dagger for dependency injection.
- Coroutines + Flow — structured concurrency instead of callbacks or RxJava.
- Room for local persistence, Retrofit (still) for networking, DataStore instead of
SharedPreferences.
If you pick any three of these, the other three are the path of least resistance. That convergence is the biggest architectural shift since Kotlin itself — you no longer have to justify the stack in a pitch doc. You just use it.
The Recommended Stack: Compose + MVVM + Hilt
The shape of a typical screen in 2025 is a thin @Composable that observes a single state object from a ViewModel, which in turn talks to a repository injected by Hilt. Nothing more.
A minimal ViewModel:
@HiltViewModel
class ProjectListViewModel @Inject constructor(
private val projectRepository: ProjectRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProjectListUiState())
val uiState: StateFlow<ProjectListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
projectRepository.observeProjects()
.catch { e -> _uiState.update { it.copy(error = e.message) } }
.collect { projects ->
_uiState.update { it.copy(projects = projects, loading = false) }
}
}
}
}
data class ProjectListUiState(
val projects: List<Project> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
)
The screen that consumes it is equally dull — and that’s the point. Dull code is readable code.
@Composable
fun ProjectListScreen(
viewModel: ProjectListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.loading -> LoadingView()
state.error != null -> ErrorView(message = state.error)
else -> ProjectList(projects = state.projects)
}
}
Two things to notice. First, collectAsStateWithLifecycle() (not the older collectAsState()) is the right call — it stops collecting when the screen goes to the background, which is what you almost always want. Second, a single UiState data class per screen is worth more than a shelf of architecture books. One source of truth, immutable, easy to test.
Hilt wires it together with minimal ceremony:
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideProjectRepository(
api: ProjectApi,
dao: ProjectDao,
): ProjectRepository = ProjectRepositoryImpl(api, dao)
}
That’s the whole pattern. Add navigation (Compose Navigation or Decompose if you need multiplatform), a sealed interface for UI events, and you have a testable, scalable architecture that fits in a junior developer’s head inside a week.
Migration Path: Fragments and Views to Compose + MVVM
Most teams I work with don’t get to start fresh — they have a five-year-old app with Fragments, XML layouts, maybe some LiveData, and possibly RxJava hanging around. Here’s the migration sequence I recommend, in order:
- Kotlin first. If there’s still Java, port it before anything else. The rest of this list is much easier in Kotlin, and it’s a prerequisite for Compose interop being painless.
- Introduce Hilt. It’s a drop-in replacement for manual DI or Dagger. New features get
@Inject; old code gradually migrates. - Migrate
LiveDatatoStateFlow. They do the same job;StateFlowcomposes better with coroutines and gives you a sensible.value. - Compose inside Fragments. Add a
ComposeViewto an existing Fragment and render new UI inside it. This is the single biggest lever — you can ship Compose to production this week without rewriting your navigation. - Convert leaf screens, then navigation. Pick screens with the fewest dependencies (settings, about, empty states) and rewrite them fully in Compose. Once most screens are Compose, migrate navigation from Fragments/Jetpack Navigation XML to Compose Navigation.
- Retire RxJava. Replace
Observable/Singlewith coroutines andFlowincrementally. RxJava and coroutines coexist fine during migration; just don’t start new features in Rx.
The order matters. Teams that try to migrate navigation and UI in the same sprint usually stall. Incremental migration with Compose-inside-Fragment is the only path that survives contact with a real backlog.
Common Pitfalls
A few traps I see repeatedly on client audits:
- Doing business logic in Composables. The Composable should call
viewModel.onXClicked()and render state. If you see aLaunchedEffectcalling a network — move it. - Multiple
MutableStateFlows per screen. One state object per screen. Multiple flows lead to impossible-to-debug state combinations and flicker. - Using
@Composablefunctions as if they were regular functions. Recomposition is not free. Hoist state, useremember, and read the Compose performance guide before you optimize. - Over-abstracting with MVI. MVI is fine, but for most apps MVVM with a single
UiStategives you 90% of the benefits without the boilerplate. Only reach for MVI when you have real event-sourcing needs. - Skipping
collectAsStateWithLifecycle(). Using plaincollectAsState()keeps your flows active in the background and drains batteries. It’s the single most common bug I find in Compose codebases. - Fragments as a crutch. Once you’re in Compose, you don’t need Fragments for navigation. Keeping them “just in case” adds lifecycle complexity you no longer need.
Where This is Going
Compose is continuing to absorb more of the platform — Compose Multiplatform is getting genuinely usable for iOS, and the tooling (layout inspector, recomposition counts, baseline profiles) has finally caught up. If you’re making decisions in 2025, bet on Compose + MVVM + Hilt. It’s boring, it’s where Google is investing, and it’s what every senior Android developer on the market already knows.
If you’re an Android team lead or CTO trying to plan a migration, that’s exactly the kind of work I do as a fractional CTO. You can see more about my Android development services or get in touch if you want a second opinion on your roadmap.