Gabe Giro

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.

AndroidArchitectureJetpack Compose

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 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:

  1. 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.
  2. Introduce Hilt. It’s a drop-in replacement for manual DI or Dagger. New features get @Inject; old code gradually migrates.
  3. Migrate LiveData to StateFlow. They do the same job; StateFlow composes better with coroutines and gives you a sensible .value.
  4. Compose inside Fragments. Add a ComposeView to 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.
  5. 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.
  6. Retire RxJava. Replace Observable/Single with coroutines and Flow incrementally. 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 a LaunchedEffect calling 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 @Composable functions as if they were regular functions. Recomposition is not free. Hoist state, use remember, and read the Compose performance guide before you optimize.
  • Over-abstracting with MVI. MVI is fine, but for most apps MVVM with a single UiState gives you 90% of the benefits without the boilerplate. Only reach for MVI when you have real event-sourcing needs.
  • Skipping collectAsStateWithLifecycle(). Using plain collectAsState() 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.