Android Architecture: MVVM, MVI, Clean Architecture, Modules

Android Development with Kotlin: 21 Real Projects · Architecture, Data and Networking · 4 min read · Updated

Layers point inward

  1. The UI layer shows state and sends events. It knows nothing about data sources.
  2. It calls use cases and interfaces in the domain layer.
  3. The data layer implements those interfaces.
  4. Repositories choose between the network and the database.

Architecture is how you split an app so it stays understandable as it grows. Good architecture lets several developers work together, makes code testable and lets you change a library without rewriting everything. This lesson explains the common patterns and what Google recommends today.

From MVC to MVVM

  • MVC: the Activity acts as both controller and view and grows huge. Avoid it.
  • MVP: a Presenter holds logic and talks to the view through an interface. Testable, but needs a lot of boilerplate.
  • MVVM: a ViewModel exposes state; the view observes it and sends events. The standard for Android, and a natural fit for Compose and Flow.
  • MVI: like MVVM, but with one immutable state object per screen and one stream of intents (user actions) that go through a reducer. It makes state changes explicit and easy to log and test.

Unidirectional data flow

In UDF, state flows down and events flow up. The UI renders one UiState and reports clicks as events. The ViewModel handles the event, updates the state and the UI redraws. You built every project this way.

data class CartUiState(val items: List<Item> = emptyList(), val loading: Boolean = false, val error: String? = null)

sealed interface CartEvent {
    data class Add(val item: Item) : CartEvent
    data class Remove(val id: Int) : CartEvent
    data object Refresh : CartEvent
}

class CartViewModel(private val repo: CartRepository) : ViewModel() {
    private val _state = MutableStateFlow(CartUiState())
    val state: StateFlow<CartUiState> = _state.asStateFlow()

    fun onEvent(e: CartEvent) {
        when (e) {
            is CartEvent.Add -> viewModelScope.launch { repo.add(e.item) }
            is CartEvent.Remove -> viewModelScope.launch { repo.remove(e.id) }
            CartEvent.Refresh -> refresh()
        }
    }
    private fun refresh() { /* set loading, call repo, update state */ }
}

Clean Architecture layers

  • Presentation (UI layer): composables and ViewModels. Knows nothing about Retrofit or Room.
  • Domain: plain Kotlin models, repository interfaces and use cases (one class per business action, like PlaceOrder). It depends on nothing else. The domain layer is optional in small apps and useful when logic is shared by several screens.
  • Data: repository implementations, remote data sources (Retrofit), local data sources (Room, DataStore) and mappers between network models, database entities and domain models.

The rule is that dependencies point inward: presentation depends on domain, and data depends on domain. A repository *interface* lives in domain; its implementation lives in data. This is dependency inversion, and it is why you can swap a fake repository into a test.

// domain
interface OrderRepository { suspend fun place(cart: List<Item>): Result<OrderId> }

class PlaceOrder(private val repo: OrderRepository) {
    suspend operator fun invoke(cart: List<Item>): Result<OrderId> {
        if (cart.isEmpty()) return Result.failure(IllegalArgumentException("empty cart"))
        return repo.place(cart)
    }
}

The repository pattern and offline-first

A repository is the single place that decides where data comes from. In an offline-first app the database is the *source of truth*: the UI reads only from Room, and the repository refreshes Room from the network in the background. The app then works with no connection and never shows two conflicting copies.

Dependency injection with Hilt

Annotate the Application with @HiltAndroidApp, Activities with @AndroidEntryPoint and ViewModels with @HiltViewModel plus @Inject constructor. A Hilt @Module with @Provides or @Binds tells Hilt how to build objects such as Retrofit or a repository. Use @Singleton for objects that should be shared, and qualifiers (@Named) when you need two of the same type. Hilt also supports multi-environment config: provide a different base URL per build flavor.

Modularization

A multi-module app splits code into Gradle modules: :app, :core:network, :core:database, :core:ui, :feature:cart, :feature:profile. Benefits are faster builds (only changed modules rebuild), enforced boundaries (use internal) and clear ownership. Features depend on core modules, never on each other; navigation between features goes through a shared interface or routes. Start with one module and split when the app or team grows.

Choosing

  • Small app or learning: MVVM with UDF, repository, no domain layer.
  • Medium app: add use cases and a clear data layer.
  • Large team: modularize, and use convention plugins and a version catalog for shared Gradle settings.

Your task

  1. Refactor one of your projects to have UiState and a sealed event class.
  2. Add a repository interface and a fake implementation, and use the fake in a ViewModel test.
  3. Write one use case with a business rule and test it.
  4. Draw your app's layers and arrows on paper and check that none point the wrong way.

How to check it worked

  • The UI holds no business logic.
  • You can swap the repository for a fake in a test.
  • You can explain unidirectional data flow.

Frequently asked questions

MVVM or MVI?

Both are fine. MVI is MVVM with stricter rules: one state object and a single event stream. Start with MVVM and move to MVI if you want stricter state handling.

Do I need a domain layer?

Not in small apps. Add it when business rules are reused or complex, so they are not buried in ViewModels.

When should I split into modules?

When build times get slow, several people work on different features, or you want clear boundaries. Not on day one.