Android Architecture series · Part 3 of 9

MVC and MVP: Taking the Logic Out of the Activity

Khairul Alam·October 2026·~10 min read
MVCMVPViewProxyPresenterKotlin

These two are where most Android apps start. You will see why MVC turns the Activity into a giant file and how MVP gives the logic its own home, with a ViewProxy keeping the view replaceable.

In 60 seconds
  • MVC: simple, but Activity becomes view and controller at once.
  • MVP: passive view, presenter holds the logic, model gives data.
  • Key trick: the presenter talks to the view through an interface (ViewProxy).
  • Cost: boilerplate and manual attach/detach.
Contents
  1. MVC: the starting point
  2. MVP: give the logic its own home
  3. Refactoring MVC to MVP

1. MVC: the starting point

Model View Controller is the oldest of the family. The Model is the data, the View is what the user sees, and the Controller reacts to the user and updates both.

Viewwhat user seesControllerreacts to inputModeldata and rules
In MVC the controller receives user input and updates the model; the view shows the model.

On Android there is a catch. The system gives you an Activity that already owns the screen, handles clicks and lifecycle. So most “MVC” apps put the view and the controller in the same class:

class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)
        findViewById<Button>(R.id.load).setOnClickListener {
            // network + parse + decide + draw, all in the same place
            Thread {
                val json = URL("https://example.com/profile").readText()
                val name = JSONObject(json).getString("name")
                runOnUiThread { findViewById<TextView>(R.id.name).text = name }
            }.start()
        }
    }
}

It works, but you cannot unit test it without an emulator, rotation can break the thread, and the file grows forever. People joke that MVC stands for Massive View Controller. This is the pain that MVP, MVVM and the others were invented to fix.

Starting point for a revamp. If your old code looks like this, great: you now know exactly which parts to pull out (network, parsing, decisions) and where they go (Data and Logic boxes).

2. MVP: give the logic its own home

Model View Presenter takes the thinking out of the Activity. The View becomes “passive”: it only draws and forwards clicks. The Presenter decides everything. The Model (repository) provides data.

ViewActivity + ViewProxyPresenterlogic, no AndroidModelrepository, dataeventshow...askdataTwo-way talk, but always through small interfaces.
The presenter holds a reference to the view only through an interface (the ViewProxy from Part 2).

The contract

MVP usually starts with a “contract” that lists what the view can do and what the presenter can do:

interface ProfileContract {
    interface View {                        // implemented by ViewProxy
        fun showLoading()
        fun showProfile(profile: Profile)
        fun showError(message: String)
    }
    interface Presenter {
        fun attach(view: View)
        fun detach()
        fun onLoadClicked(id: String)
    }
}

The presenter

class ProfilePresenter(
    private val repo: ProfileRepository,
    private val dispatchers: DispatcherProvider,
) : ProfileContract.Presenter {

    private var view: ProfileContract.View? = null
    private val scope = CoroutineScope(SupervisorJob() + dispatchers.main)

    override fun attach(view: ProfileContract.View) { this.view = view }

    override fun detach() {            // call from onDestroy to avoid leaks
        view = null
        scope.coroutineContext.cancelChildren()
    }

    override fun onLoadClicked(id: String) {
        view?.showLoading()
        scope.launch {
            try {
                val profile = withContext(dispatchers.io) { repo.getProfile(id) }
                view?.showProfile(profile)
            } catch (e: IOException) {
                view?.showError("No connection")
            }
        }
    }
}

The view

class ProfileActivity : AppCompatActivity() {
    private lateinit var presenter: ProfileContract.Presenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityProfileBinding.inflate(layoutInflater)
        setContentView(binding.root)

        presenter = ProfilePresenter(repo = /* injected */, dispatchers = /* injected */)
        presenter.attach(ProfileViewProxy(binding))
        binding.load.setOnClickListener { presenter.onLoadClicked("42") }
    }

    override fun onDestroy() {
        presenter.detach()
        super.onDestroy()
    }
}

Notice what changed. The presenter has no Android imports (no Context, no View), so a plain JUnit test can create it with a fake repository and a fake view. The activity shrank to wiring.

Pros and cons

GoodWatch out
Very easy to unit test the presenter.Boilerplate: a contract and an interface per screen.
Clear rule: view is dumb, presenter is smart.You manage attach and detach yourself. Forgetting detach() leaks the Activity.
Works with any UI technology (XML, Compose, even a different platform).On rotation the presenter is recreated unless you keep it somewhere (Part 7).
Great first step when refactoring old MVC code.Presenters can grow large if you put everything in them. Split with use cases (Part 5).

3. Refactoring MVC to MVP, step by step

  1. Create a repository for the network call. Move parsing there.
  2. Write the View interface: one method per thing the screen can show.
  3. Move click-handling decisions into a presenter that calls the view interface.
  4. Make the Activity (or a ViewProxy) implement the view interface.
  5. Add a unit test for the presenter before you move on to the next screen.
What to remember

MVP = MVC where the controller got its own class, lost its Android dependencies, and talks to the view only through an interface.

← Back to all writing