From tangled Activities to Clean Architecture — a deep journey through every pattern that shaped modern Android development.
Android launched in 2008 with a simple premise: Activity is your app. Developers coming from desktop or web backgrounds had no established mobile architecture guidelines. An Activity handled UI rendering, user input, business logic, network calls, database operations — everything poured into a single God class.
This era was characterized by "God Activities" — single files that could span thousands of lines, doing UI manipulation, HTTP requests, SQLite queries, and calculations all in one place. There was no separation of concerns, no testability, and rotating the screen would destroy your Activity and wipe any in-progress state.
The "God Activity" problem was so severe that developers started borrowing MVC from web frameworks (Rails, Django, Spring MVC). The goal: split the monolith into three distinct roles so that business logic no longer lived inside UI code. It was the first serious separation of concerns for Android apps.
MVC divides the app into three layers: Model (data and business rules), View (UI representation, often XML layouts), and Controller (the Activity/Fragment that bridges the two). When a user interacts with the View, it notifies the Controller. The Controller processes it, updates the Model, and the Model updates the View.
On Android, the mapping was awkward from the start. Activities are neither pure Controllers nor pure Views — they touch both. The XML layouts became the "View," the Activity became the "Controller," and plain Java/Kotlin classes became "Models." This blurry boundary meant the Activity still grew large, even if less chaotic.
User taps button → View notifies Controller → Controller calls Model → Model updates data → Model notifies Controller → Controller updates View. Simple linear flow, but in Android the View can also directly access the Controller since the Activity is both.
Android's Activity violates pure MVC because it's responsible for both View inflation and Controller logic. You can't cleanly separate them. The XML is the "View" but the Activity controls it, making Activity still massive — just better organised.
MVC's Controller (Activity) was inseparable from Android's framework — you couldn't test it with plain JUnit. MVP drew a strict interface contract between the Presenter and the View. The Activity implements a View interface, the Presenter holds a reference to that interface (not the Activity itself). Now you can mock the View in tests and write real unit tests for all presentation logic. This was Android testing's first big leap.
In MVP, the View (Activity/Fragment) is deliberately dumb — it only renders data pushed to it and delegates all user events to the Presenter. The Presenter contains all UI logic and calls Model. Critically, the View and Presenter communicate only through a defined interface (contract), making both sides independently replaceable and testable.
The Presenter holds a reference to the View interface. When the Activity is destroyed (rotation), the Presenter must detach the View reference to avoid memory leaks. This explicit lifecycle management became a common source of bugs and boilerplate — every screen needed a View interface, a Presenter class, and careful attach/detach logic.
Every screen defines an IView interface (e.g., showUserList(), showError(), showLoading()). The Activity implements it. The Presenter only knows the interface — never the Activity. Mock the interface in tests and the Presenter becomes fully testable with JUnit.
Every feature requires: an IView interface, a Presenter class, an IPresenter interface (optionally), plus careful attachView() / detachView() calls in every Activity lifecycle method. Complex screens accumulate massive interface definitions.
MVP required developers to manually call presenter.attachView(this) and presenter.detachView() in every Activity — forget once and you had a memory leak or a crash. MVVM's ViewModel is lifecycle-aware by design: it survives rotation and is automatically cleared when the screen is permanently destroyed. LiveData/StateFlow only emits to active observers, so no null pointer crashes from posting to a destroyed View. All that manual lifecycle glue code vanished.
MVVM's ViewModel holds and exposes UI state via LiveData or StateFlow. The View (Activity/Fragment) observes these streams — it reacts to state changes rather than being instructed step by step. There is no callback or method call from ViewModel back to the View; data flows one direction: ViewModel → View via reactive streams.
The ViewModel has no reference to the View at all, making memory leaks structurally impossible. When combined with Data Binding, the XML itself can observe LiveData directly, further reducing Activity boilerplate. With Kotlin Coroutines and viewModelScope, async operations are automatically cancelled when the ViewModel is cleared — handling the async problem MVP never solved cleanly.
ViewModel is scoped to the UI lifecycle — it survives rotation but is cleared when the screen closes. LiveData/StateFlow are lifecycle-aware observers. You literally cannot leak a View reference from a ViewModel, since ViewModel has no View reference at all.
Compose's collectAsState() pairs perfectly with StateFlow. The ViewModel emits a single UiState data class, Compose recomposes reactively. This is Google's current gold-standard pattern for new Android apps in 2024.
Every coroutine launched in viewModelScope is automatically cancelled when the ViewModel is cleared. No more RxJava CompositeDisposable management. No more manually cancelling network requests. Async code reads like synchronous code with suspend fun, and structured concurrency handles cleanup for you.
MVVM often has multiple LiveData/StateFlow properties exposed — isLoading, userList, errorMessage as three separate fields. This allows impossible states: what if isLoading = true AND userList is non-empty AND errorMessage is non-null simultaneously? MVI enforces a single sealed UiState (Loading | Success | Error) — only one valid state exists at any moment, making impossible states structurally impossible.
MVI is inspired by Redux (JavaScript) and Elm architecture. The core principle is Unidirectional Data Flow (UDF): the View emits Intents (user actions), the ViewModel processes them via a Reducer (a pure function that takes current state + intent → new state), and emits a single State back to the View. The flow is strictly one direction.
Because state is a single immutable data class (or sealed class), you can snapshot state at any point in time. This enables powerful features: logging every state transition for debugging, time-travel debugging, writing deterministic tests by simply feeding a state + intent → asserting the next state. Side effects (navigation, toasts) are handled separately as Effects/Events via Channel.
Instead of isLoading: Boolean + data: List + error: String, you have one type: sealed class UiState { Loading, Success(data), Error(msg) }. Impossible combinations are impossible by the type system itself.
Every state your app has ever been in is a serializable data object. Log them all. Replay them. Write tests that say "given state X, when user sends Intent Y, assert new state Z." No mocking, no Mockito — pure functional assertions.
Navigation, SnackBars, and Toast messages are side effects — they happen once and aren't part of persistent state. MVI handles these separately via Channel (consumed exactly once), solving the MVVM problem of LiveData re-delivering events on rotation.
Compose's recomposition model aligns perfectly with MVI — a single state object drives the entire UI tree. State hoisting, collectAsState(), and sealed UiState classes make MVI the natural architecture for Compose-first apps.
MVVM and MVI tell you how the UI layer works. But where do you put business logic that has nothing to do with UI? Where does domain knowledge live — "a user can only checkout if their cart is non-empty" — that is a rule that doesn't belong in a ViewModel nor in a Repository. Clean Architecture introduces a Domain Layer with Use Cases for exactly this. It also enforces the Dependency Rule: inner layers (domain) must never know about outer layers (UI, database). This makes the business logic completely independent of Android, databases, and frameworks.
Clean Architecture (Robert C. Martin, 2012) organizes code into concentric rings: Entities (core business objects) → Use Cases / Interactors (application-specific business rules) → Interface Adapters (Presenters/ViewModels, Repositories) → Frameworks & Drivers (Android, Room, Retrofit, UI). The fundamental rule: source code dependencies point only inward. Nothing in an inner ring knows anything about outer rings.
For Android, this typically maps to three modules: Presentation (Activities, Fragments, ViewModels, Composables), Domain (Use Cases, Repository interfaces, Domain Models — zero Android dependencies), and Data (Repository implementations, Room DAOs, Retrofit APIs, Mappers). The Domain module is pure Kotlin — it can be tested with JUnit alone, on any JVM, with zero Android SDK.
Domain layer imports nothing from Android, Room, or Retrofit. It defines interfaces. The Data layer implements those interfaces. The Presentation layer calls Use Cases. Dependencies point inward — Domain is the king, it knows nothing of its servants.
Each Use Case is one class, one responsibility: GetUserUseCase, CheckoutUseCase, ValidateEmailUseCase. They are reusable across multiple ViewModels, contain pure business rules, and are testable with a single mock of the Repository interface.
Domain defines interface UserRepository { suspend fun getUser(id: String): User }. Data module provides UserRepositoryImpl using Room + Retrofit. At test time, inject a FakeUserRepository. Domain never changes when you swap SQLite for Firestore.
Clean Architecture naturally maps to Gradle modules: :app, :domain, :data, and feature modules. The :domain module has no Android dependencies — it compiles fast and tests instantly. This structure also enables better build times at scale.
Every architecture at a glance — pick what fits your project's needs
| Pattern | Era | Testability | State Management | Boilerplate | Config Change | Best For |
|---|---|---|---|---|---|---|
| No Arch | 2008–2012 | ❌ Nearly impossible | Ad-hoc in Activity | None (but chaos) | ❌ Destroy & recreate | Prototypes only |
| MVC | 2010–2014 | ⚠️ Model only | Controller manages it | Low | ❌ Controller re-created | Simple apps, learning |
| MVP | 2013–2018 | ✅ Presenter testable | Presenter owns it | High (interfaces) | ⚠️ Manual save/restore | Legacy codebases |
| MVVM | 2017–Present | ✅ ViewModel testable | Multiple LiveData/Flow | Medium | ✅ ViewModel survives | Most Android apps today |
| MVI | 2019–Present | ✅✅ Reducer is pure fn | Single sealed UiState | Medium-High | ✅ ViewModel survives | Complex state, Compose |
| Clean + MVVM/MVI | 2021–Present | ✅✅✅ Every layer | MVVM or MVI on top | Very High | ✅✅ Full lifecycle | Production apps, teams |