Android Architecture series · Part 9 of 9

Converting Between Architectures: Recipes, Code and Diagrams

Khairul Alam·October 2026·~14 min read
MigrationRefactoringMVPMVVMReduxVIPER

Since every architecture is the same three boxes, moving between them is mostly mechanical. This part gives you before/after code, mapping diagrams and a cheat sheet for each direction.

In 60 seconds
  • Rule: one screen at a time, one box at a time, tests first.
  • MVP → MVVM: view.showX() becomes state = X; use a bridge ViewModel to go gradually.
  • MVVM → Redux: methods become actions, assignments move to a reducer.
  • To VIPER: extract an interactor and a router.
Contents
  1. Why conversion is mostly mechanical
  2. MVC to MVP
  3. MVP to MVVM
  4. MVVM to Redux / MVI
  5. MVP to Redux / MVI
  6. To and from VIPER
  7. Conversion cheat sheet
  8. A migration plan for a big codebase

1. Why conversion is mostly mechanical

Part 8 proved that MVP, MVVM, VIPER and Redux share the same three boxes. That has a practical payoff: you can move from one to another without rewriting the app. The View stays passive, the Data layer stays untouched, and the shared core (a use case such as LoadProfile) is reused as-is. Only the logic box changes shape, and only in one respect: how results reach the view.

The golden rules of any conversion
  • Convert one screen at a time. Old and new live side by side.
  • Write (or keep) a test for the old behavior first. It is your safety net.
  • Move one box per step: first extract the core, then change the logic box, then the view.
  • Never touch the Data layer while converting the logic layer.

We will reuse the Profile feature from the earlier parts so every before/after is comparable.

MVCMVPMVVMVIPERRedux / MVIsection 2section 3section 6section 4section 5Every arrow also works backwards. The recipe is the same read in reverse.
Most real migrations are MVC to MVP to MVVM, then optionally towards Redux for complex screens or towards VIPER-style use cases for scale.

2. MVC to MVP

Covered in detail in Part 3. In one picture: the Activity splits into a passive view and a presenter.

Activity: onClick handlerView: forwards event to presenterActivity: network and parsingRepository (Data box)Activity: decide what to showPresenterActivity: findViewById / setTextViewProxy: showProfile(), showError()
Each responsibility of the fat Activity gets its own home.

3. MVP to MVVM

This is the most common migration, because Jetpack makes MVVM the easy path. The core change: instead of the presenter calling view methods, the ViewModel writes state.

presenter.attach(view) / detach()gone: ViewModel has no viewview?.showLoading()state.value = Loadingview?.showProfile(p)state.value = Content(p)view?.showError(msg)state.value = Error(msg)CoroutineScope in presenterviewModelScopeProfileContract.View interfaceProfileUiState + render(state)
Line-by-line translation from presenter calls to state assignments.

Before: MVP presenter

class ProfilePresenter(private val repo: ProfileRepository, private val dispatchers: DispatcherProvider) {
    private var view: ProfileContract.View? = null
    private val scope = CoroutineScope(SupervisorJob() + dispatchers.main)
    fun attach(v: ProfileContract.View) { view = v }
    fun detach() { view = null; scope.coroutineContext.cancelChildren() }

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

After: MVVM ViewModel

class ProfileViewModel(private val repo: ProfileRepository, private val dispatchers: DispatcherProvider) : ViewModel() {
    private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val state: StateFlow<ProfileUiState> = _state.asStateFlow()

    fun onLoadClicked(id: String) {                       // same name, same body shape
        _state.value = ProfileUiState.Loading             // was view?.showLoading()
        viewModelScope.launch {
            _state.value = try { ProfileUiState.Content(withContext(dispatchers.io) { repo.getProfile(id) }) }
                           catch (e: IOException) { ProfileUiState.Error("No connection") }
        }
    }
}

The safe path: a bridge adapter

If the presenter is large and well tested, do not rewrite it yet. Convert the view side first with a ViewModel that implements the old view interface and turns every call into state. The presenter does not change at all.

Old PresenterunchangedBridge ViewModelimplements ViewStateFlowProfileUiStateNew viewcollectsshowX()state =render()
Step 1: new view, old presenter. Step 2 (later): fold the presenter into the ViewModel and delete the bridge.
class BridgeProfileViewModel(repo: ProfileRepository, dispatchers: DispatcherProvider)
    : ViewModel(), ProfileContract.View {

    private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val state: StateFlow<ProfileUiState> = _state.asStateFlow()

    private val presenter = ProfilePresenter(repo, dispatchers).also { it.attach(this) }

    override fun showLoading() { _state.value = ProfileUiState.Loading }
    override fun showProfile(profile: Profile) { _state.value = ProfileUiState.Content(profile) }
    override fun showError(message: String) { _state.value = ProfileUiState.Error(message) }

    fun onLoadClicked(id: String) = presenter.onLoadClicked(id)
    override fun onCleared() { presenter.detach() }
}

Bonus: as a side effect the presenter now survives rotation, because the ViewModel holds it.

MVVM back to MVP (rare, for example sharing logic with a non-ViewModel host): read the table right to left. Collect state in the view and call the proxy, or have the presenter call the proxy directly.

4. MVVM to Redux / MVI (and back)

Here the screen already has state. You are adding discipline: every change goes through one function.

fun load(id)Action: Load(id)fun onRetry()Action: Retry_state.value = Loadingreduce(state, Load)_state.value = Content(p)reduce(state, Loaded(p))Many setters, many placesOne pure reduce() function
Each public method becomes an Action; each assignment moves into the reducer.
// Step 1: keep the ViewModel. Add Action and a pure reduce().  (reduce() is shown in Part 4)
// Step 2: replace the many public methods with ONE entry point.
class ProfileViewModel(private val loadProfile: LoadProfile) : ViewModel() {
    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    fun onAction(action: ProfileAction) {
        _state.update { reduce(it, action) }                    // the ONLY place state changes
        if (action is ProfileAction.Load) viewModelScope.launch {   // effects stay outside the reducer
            loadProfile(action.id)
                .onSuccess { onAction(ProfileAction.Loaded(it)) }
                .onFailure { onAction(ProfileAction.Failed("No connection")) }
        }
    }
}

This is the cheapest way to try Redux/MVI: keep the ViewModel as the store. The view only needs viewModel.onAction(...) instead of several method names. Going back to plain MVVM is just inlining: each reduce branch becomes the body of a ViewModel method.

5. MVP to Redux / MVI

Same idea as section 4, with one extra step: collect the scattered presenter state into one object first.

  1. List everything the view can show (loading, profile, error). That list is ProfileState.
  2. Turn each presenter method (onLoadClicked) into an Action.
  3. Turn each view?.showX() into a new state produced by reduce.
  4. Replace the view interface with one function: render(state: ProfileState).
// MVP view interface: three commands
interface View { fun showLoading(); fun showProfile(p: Profile); fun showError(m: String) }

// Redux view: one function, the proxy decides what to hide/show
fun ProfileViewProxy.render(s: ProfileState) {
    progress.isVisible = s.loading
    s.profile?.let { name.text = it.name }
    error.text = s.error.orEmpty()
}

6. MVP or MVVM to VIPER (and back)

You are not changing how results reach the view. You are splitting the logic box into smaller roles.

Presenter: validate and call repoInteractor (a use case class)Presenter: format for the viewPresenter (thin)Presenter / events: open next screenRouterData classes shared with data layerEntity (plain model)
VIPER is the same logic, sliced into four classes.
// Before (MVVM): everything in the ViewModel
fun onLoad(id: String) = viewModelScope.launch {
    val p = repo.getProfile(id)
    if (!p.active) _state.value = Error("Profile disabled") else _state.value = Content(p)   // rule
}
fun onSettings() { _events.trySend(Navigate.Settings) }                                       // navigation

// After (VIPER): the rule, the navigation and the glue are separated
class LoadProfileInteractor(private val repo: ProfileRepository) {              // I
    suspend operator fun invoke(id: String) = runCatching { repo.getProfile(id) }
        .mapCatching { if (it.active) it else error("Profile disabled") }
}
interface ProfileRouter { fun openSettings() }                                  // R
class ProfilePresenter(view: ProfileView, interactor: LoadProfileInteractor, router: ProfileRouter) // P

Back from VIPER to MVVM: inject the interactor into the ViewModel (it becomes a domain use case), turn the presenter's formatting into the state mapping, and turn router calls into one-time navigation events that the view observes. Many modern apps end up exactly here: an MVVM screen with use cases, which is VIPER's I and E without the heavy P and R.

7. Conversion cheat sheet

From → ToWhat movesWhat staysEffort
MVC → MVPDecisions leave the Activity; view interface appearsLayouts, repositoryMedium (per screen)
MVP → MVVMview.showX() becomes state = X; delete attach/detachRepository, use cases, layoutsLow (use the bridge)
MVVM → MVPState assignments become view callsEverything elseLow
MVVM → ReduxMethods become actions; assignments move to reduce()ViewModel as store, view collectionLow to medium
Redux → MVVMInline each reducer branch into a methodState classLow
MVP/MVVM → VIPERExtract interactor and routerView, repositoryMedium
VIPER → MVVMPresenter merges into ViewModel; interactor stays as use caseInteractor, entity, repositoryLow

Tap a row to open its diagram and a before/after code example.

MVC → MVPpull logic out of the Activity
MVCMVPActivity: click listenerView forwards to presenterActivity: network + parseRepositoryActivity: decisionsPresenterActivity: setText / visibilityViewProxy methods
Where each piece of MVC goes in MVP.

Before (MVC)

button.setOnClickListener {
    Thread {
        val name = JSONObject(URL(url).readText()).getString("name")
        runOnUiThread { nameView.text = name }
    }.start()
}

After (MVP)

button.setOnClickListener { presenter.onLoadClicked(id) }     // view: forward only
// presenter: view.showLoading(); repo.getProfile(id); view.showProfile(p)
// proxy:     override fun showProfile(p: Profile) { binding.name.text = p.name }
MVP → MVVMcalls become state
MVPMVVMpresenter.attach / detachremovedview?.showLoading()state = Loadingview?.showProfile(p)state = Content(p)presenter scopeviewModelScope
Where each piece of MVP goes in MVVM.

Before (MVP)

view?.showLoading()
scope.launch {
    view?.showProfile(repo.getProfile(id))
}

After (MVVM)

_state.value = ProfileUiState.Loading
viewModelScope.launch {
    _state.value = ProfileUiState.Content(repo.getProfile(id))
}
MVVM → MVPstate becomes calls
MVVMMVPstate = Loadingview.showLoading()state = Content(p)view.showProfile(p)view collects StateFlowview implements interfaceviewModelScopepresenter scope + detach()
Where each piece of MVVM goes in MVP.

Before (MVVM)

_state.value = ProfileUiState.Loading
_state.value = ProfileUiState.Content(profile)

After (MVP)

view?.showLoading()
view?.showProfile(profile)     // attach(view) in onCreate, detach() in onDestroy
MVVM → Redux / MVImethods become actions
MVVMRedux / MVIfun load(id)Action: Load(id)_state.value = ...reduce(state, action)several public methodsone onAction()ViewModelstays as the store
Where each piece of MVVM goes in Redux / MVI.

Before (MVVM)

fun load(id: String) {
    _state.value = _state.value.copy(loading = true)
    viewModelScope.launch { /* ... _state.value = ... */ }
}

After (Redux / MVI)

fun onAction(a: ProfileAction) {
    _state.update { reduce(it, a) }          // the only place state changes
    if (a is ProfileAction.Load) runEffect(a.id)
}
Redux / MVI → MVVMinline the reducer
Redux / MVIMVVMAction: Load(id)fun load(id)reduce() branchmethod bodyonAction() entry pointremovedStoreViewModel
Where each piece of Redux / MVI goes in MVVM.

Before (Redux / MVI)

is ProfileAction.Load   -> state.copy(loading = true)
is ProfileAction.Loaded -> state.copy(loading = false, profile = action.profile)

After (MVVM)

fun load(id: String) { _state.update { it.copy(loading = true) }; /* fetch */ }
private fun onLoaded(p: Profile) { _state.update { it.copy(loading = false, profile = p) } }
MVP / MVVM → VIPERsplit the logic box
MVP / MVVMVIPERPresenter: rulesInteractorPresenter: formattingPresenter (thin)Navigation callsRouterData classesEntity
Where each piece of MVP / MVVM goes in VIPER.

Before (MVP / MVVM)

fun onLoad(id: String) = scope.launch {
    val p = repo.getProfile(id)
    if (p.active) view.showProfile(p) else view.showError("Disabled")
}

After (VIPER)

class LoadProfileInteractor(private val repo: ProfileRepository) {      // rule moved here
    suspend operator fun invoke(id: String) = runCatching { repo.getProfile(id) }
        .mapCatching { if (it.active) it else error("Disabled") }
}
// Presenter: interactor(id).onSuccess(view::showProfile).onFailure { view.showError(it.message!!) }
VIPER → MVVMmerge presenter, keep use case
VIPERMVVMInteractoruse case injected into VMPresenterViewModel (state mapping)Routernavigation eventsEntitydomain model
Where each piece of VIPER goes in MVVM.

Before (VIPER)

presenter.onSettingsClicked(id)     // -> router.openSettings(id)
interactor(id).onSuccess(view::showProfile)

After (MVVM)

class ProfileViewModel(private val loadProfile: LoadProfileInteractor) : ViewModel() {
    private val _events = Channel<Nav>(); val events = _events.receiveAsFlow()
    fun onSettingsClicked(id: String) { _events.trySend(Nav.Settings(id)) }   // view navigates
}

8. A migration plan that works on a big codebase

Pinadd tests to old codeExtractcore / use caseSwaplogic boxCleandelete the bridge
Four-step loop per screen: pin the behavior with tests, extract the core, swap the logic box, then clean up.
  1. Pin: write tests around the screen's current behavior (the same test shape from Part 8).
  2. Extract: pull the business rule into a use case such as LoadProfile. Both old and new logic will call it.
  3. Swap: change the logic box (presenter to ViewModel, ViewModel to store). Use a bridge if the change is big.
  4. Clean: remove the bridge and the unused interface; run the same tests; ship.
  5. Pick the next screen. Order by pain: start with a screen that changes often and is hard to test.

Do not convert for the label. Convert only when the move removes a real cost (bugs on rotation, untestable logic, merge conflicts). If a stable MVP screen works and nobody touches it, leave it alone.

← Back to all writing