MVC, MVP, MVVM, Clean, and MVI Explained

Amitabh SharmaAmitabh Sharma
9 min read

Introduction: Why Architecture Matters

If you’ve been building Android apps for a while, you’ve probably realized that as your app grows, things start to get messy—activities stuffed with business logic, network calls sprinkled everywhere, and UI updates tangled with data handling.

The solution? A solid architecture.

A good architecture:

  • Keeps your code clean, maintainable, and testable

  • Makes scaling easy as your app grows

  • Improves team collaboration

In this blog, we’ll take a journey through the evolution of Android architecture, from classic MVC to modern MVI, including MVP, MVVM, and Clean Architecture—with diagrams and Kotlin examples.

The Evolution of Android Architecture:

Why so many patterns? Because apps evolved.

  • Early Android (MVC): Activities controlled everything.

  • MVP: Introduced a Presenter to reduce Activity bloat.

  • MVVM: Google’s preferred pattern for lifecycle awareness and reactive UIs.

  • Clean Architecture: Perfect for enterprise-scale apps.

  • MVI: Great for state-driven UI like Jetpack Compose.

1. MVC (Model-View-Controller) – The Classic Pattern

What is MVC?

MVC is one of the oldest design patterns that divides an application into three interconnected components:

  • Model: Represents data and business logic. Handles data storage, API calls, and database operations.

  • View: Represents the user interface. Displays data to the user and sends user actions to the Controller.

  • Controller: Acts as a mediator between View and Model. It processes user input, updates the Model, and refreshes the View.

Diagram:

MVC Folder Structure:

app/
 ├── model/
 │    └── User.kt
 ├── view/
 │    └── activity_main.xml
 ├── controller/
 │    └── MainActivity.kt  // Acts as both Controller & View in Android

Data Flow:

  • Controller fetches Model data

  • Updates View when data changes

Kotlin Code Example:

// Model: Represents data
data class User(val name: String, val age: Int)

// View Interface: Defines UI contract
interface UserView {
    fun showUser(user: User)
}

// Controller: Handles logic
class UserController(private val view: UserView) {
    fun getUser() {
        val user = User("John", 25)
        view.showUser(user)
    }
}

// View Activity: Implements View
class MainActivity : AppCompatActivity(), UserView {
    private lateinit var controller: UserController

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        controller = UserController(this)
        controller.getUser()
    }

    override fun showUser(user: User) {
        findViewById<TextView>(R.id.textView).text = "${user.name}, ${user.age}"
    }
}

Pros:

  • Simple and easy to start

  • Good for very small apps

Cons:

  • Activity often becomes God class (does everything)

  • Hard to maintain and test for big applications.

  • Tight coupling between UI and logic

2. MVP (Model-View-Presenter) – The First Big Step

What is MVP?

MVP is an improvement over MVC, introduced to separate UI logic from business logic more effectively.

  • Model: Data layer (APIs, DB).

  • View: UI layer, represented by Activity or Fragment (implements a View Interface).

  • Presenter: Contains presentation logic, communicates with Model and updates View.

Diagram:

MVP Folder Structure

app/
 ├── model/
 │    └── User.kt
 ├── presenter/
 │    └── UserPresenter.kt
 ├── view/
 │    ├── UserView.kt      // Interface
 │    └── MainActivity.kt  // Implements UserView

Kotlin Code Example:

// Model: Represents data
data class User(val name: String, val age: Int)

// View : Interface
interface UserView {
    fun showUser(user: User)
}

// Presenter
class UserPresenter(private val view: UserView) {
    fun loadUser() {
        val user = User("John Doe", 30)
        view.showUser(user)
    }
}

// View:  Activity
class MainActivity : AppCompatActivity(), UserView {
    private lateinit var presenter: UserPresenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        presenter = UserPresenter(this)
        presenter.loadUser()
    }

    override fun showUser(user: User) {
        findViewById<TextView>(R.id.textView).text = "${user.name}, ${user.age}"
    }
}

Pros:

  • Easier to test Presenter since it’s independent of Android components.

  • View is passive and only displays data provided by Presenter.

Cons:

  • Presenter can still grow very large in complex apps.

  • Requires manual binding between View and Presenter.

3. MVVM (Model-View-ViewModel) – The Modern Standard

What is MVVM?

MVVM is a pattern recommended by Google for Android. It uses data binding or reactive streams to reduce boilerplate code.

  • Model: Responsible for data and business logic.

  • View: UI layer (Activity, Fragment) observes ViewModel.

  • ViewModel: Stores UI-related data, survives configuration changes, and exposes data via LiveData or StateFlow.

Diagram:

MVVM Folder Structure:

app/
 ├── model/
 │    └── User.kt
 ├── view/
 │    ├── activity_main.xml
 │    └── MainActivity.kt
 ├── viewmodel/
 │    └── UserViewModel.kt

Kotlin Code Example:

// ======================
// Model
// ======================
data class User(val name: String, val age: Int)

// ======================
// ViewModel
// Holds the UI data and business logic
// ======================
class UserViewModel : ViewModel() {

    // LiveData to observe changes in UI
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user

    // Simulate fetching user data (normally from Repository)
    fun fetchUser() {
        _user.value = User("John Doe", 30)
    }
}

// ======================
// View (Activity)
// Observes the ViewModel and updates the UI
// ======================
class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: UserViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Initialize ViewModel
        viewModel = ViewModelProvider(this).get(UserViewModel::class.java)

        // Observe LiveData for changes
        viewModel.user.observe(this) { user ->
            // Update UI when data changes
            findViewById<TextView>(R.id.textView).text = "${user.name}, ${user.age}"
        }

        // Fetch user data when Activity starts
        viewModel.fetchUser()
    }
}

Pros:

  • No direct reference between View and ViewModel (reduces memory leaks).

  • Great for reactive UI with LiveData or Flow.

  • Highly testable.

Cons:

  • Slightly more complex than MVP for beginners.

4. Clean Architecture – For Large Apps

What is Clean Architecture?

Clean Architecture is a layered architecture proposed by Robert C. Martin (Uncle Bob). It organizes code into layers with strict dependency rules:

  • Presentation Layer: UI and ViewModel.

  • Domain Layer: Business rules, use cases, entities (pure Kotlin/Java, independent of Android).

  • Data Layer: Repositories, APIs, databases.

Principles:

  • Dependency direction is always inward (UI → Domain → Data).

  • Each layer is independent and testable.

Best Practice:

  • Use Hilt for DI

  • Coroutines + Flow for async tasks

  • Multi-module for faster builds

Diagram:

Clean Architecture Folder Structure:

app/
 ├── presentation/
 │    ├── view/
 │    │    └── MainActivity.kt
 │    └── viewmodel/
 │         └── UserViewModel.kt
 ├── domain/
 │    ├── model/
 │    │    └── User.kt
 │    ├── repository/
 │    │    └── UserRepository.kt
 │    └── usecase/
 │         └── GetUserUseCase.kt
 ├── data/
 │    ├── repository/
 │    │    └── UserRepositoryImpl.kt
 │    ├── remote/
 │    │    └── ApiService.kt
 │    └── local/
 │         └── UserDao.kt

Kotlin Code Example:

// =============================
// Domain Layer (Independent of frameworks)
// =============================

// Entity (Business object)
data class User(val name: String, val age: Int)

// Repository Interface (Abstraction)
interface UserRepository {
    fun getUser(): User
}

// Use Case (Business logic)
class GetUserUseCase(private val repository: UserRepository) {
    operator fun invoke(): User {
        // Here you can add additional business rules if needed
        return repository.getUser()
    }
}

// =============================
// Data Layer (Implements Repository)
// =============================

// Repository Implementation (Uses data sources)
class UserRepositoryImpl : UserRepository {
    override fun getUser(): User {
        // In real-world, fetch from API or Database
        return User("John Doe", 30)
    }
}

// =============================
// Presentation Layer (UI + ViewModel)
// =============================

class UserViewModel(private val getUserUseCase: GetUserUseCase) : ViewModel() {

    // LiveData to expose data to UI
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> get() = _user

    fun fetchUser() {
        // Call the UseCase to get user data
        _user.value = getUserUseCase()
    }
}

// =============================
// View (Activity)
// =============================

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: UserViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Manual DI for simplicity (Hilt/Dagger recommended in real projects)
        val repository = UserRepositoryImpl()
        val useCase = GetUserUseCase(repository)
        viewModel = UserViewModel(useCase)

        // Observe LiveData to update UI
        viewModel.user.observe(this) { user ->
            findViewById<TextView>(R.id.textView).text = "${user.name}, ${user.age}"
        }

        // Fetch user when screen loads
        viewModel.fetchUser()
    }
}

Pros:

  • Scalable and maintainable for large applications.

  • Allows easy replacement of frameworks or data sources.

Cons:

  • Initial setup requires more effort and boilerplate.

5. MVI (Model-View-Intent) – The Reactive Future

What is MVI?

MVI is a reactive, unidirectional architecture inspired by Redux. It focuses on managing state in a predictable way.

  • Model: Represents the entire UI state as an immutable object.

  • View: Displays the state and sends user intents.

  • Intent: Represents user actions, which trigger state changes.

Diagram:

MVI Folder Structure:

app/
 ├── intent/
 │    └── UserIntent.kt
 ├── model/
 │    └── UserState.kt
 ├── view/
 │    └── MainActivity.kt
 ├── viewmodel/
 │    └── UserViewModel.kt
 ├── repository/
 │    └── UserRepository.kt

Kotlin Code Example:

// Intent: Represents user actions
sealed class UserIntent {
    object LoadUser : UserIntent()
}

// State: Represents UI state
data class UserState(val name: String = "", val age: Int = 0)

// ViewModel: Processes intents and updates state
class UserViewModel : ViewModel() {

    private val _state = MutableLiveData<UserState>()
    val state: LiveData<UserState> = _state

    // Handle user intents
    fun processIntent(intent: UserIntent) {
        when (intent) {
            is UserIntent.LoadUser -> {
                // Update state when user is loaded
                _state.value = UserState("John Doe", 30)
            }
        }
    }
}

// View (Activity/Fragment): Observes state and sends intents
class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: UserViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        viewModel = ViewModelProvider(this).get(UserViewModel::class.java)

        // Observe state changes
        viewModel.state.observe(this) { state ->
            findViewById<TextView>(R.id.textView).text = "${state.name}, ${state.age}"
        }

        // Send an intent to load user
        findViewById<Button>(R.id.button).setOnClickListener {
            viewModel.processIntent(UserIntent.LoadUser)
        }
    }
}

Pros:

  • Single source of truth for UI state.

  • Easy debugging (predictable state changes).

  • Ideal for Jetpack Compose or declarative UI.

Cons:

  • Can be verbose and complex for simple apps.

Comparison Table:

PatternComplexityTestabilityData FlowBest For
MVCLowLowBi-directional (Controller updates View & Model)Small apps
MVPMediumMediumBi-directional (Presenter mediates View & Model)Medium apps
MVVMMediumHighOne-way reactive (View observes ViewModel)Most apps
CleanHighVery HighLayered one-way (UI → Domain → Data)Large teams
MVIHighHighUnidirectional (Intent → State → View)Compose UIs

Conclusion: Which Architecture Should You Choose?

Android architecture patterns have evolved for a reason—to make your code maintainable, scalable, and testable. There’s no one-size-fits-all solution; the right pattern depends on your app’s complexity, team size, and long-term goals.

  • MVC – Use only for tiny apps or quick prototypes.

  • MVP – Works well for small to medium apps where you want some structure but don’t need advanced features.

  • MVVM – The modern standard for most Android apps, especially when using Jetpack components or Compose.

  • Clean Architecture – Best for enterprise apps, large teams, and projects requiring scalability and testability.

  • MVI – Ideal for reactive UIs and Jetpack Compose, providing a single source of truth for state.

My Recommendation:

  • Start with MVVM if you’re building modern apps.

  • Move to Clean Architecture for large, complex projects.

  • Consider MVI if you’re developing with Jetpack Compose or need unidirectional data flow.

Your Turn: Which architecture pattern do you use in your apps? Drop a comment below and let’s discuss!

0
Subscribe to my newsletter

Read articles from Amitabh Sharma directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Amitabh Sharma
Amitabh Sharma

I am a mobile application developer with more than 10 years of experience in developing native and cross platform mobile apps.