Android Architecture series · Part 9 of 9
Converting Between Architectures: Recipes, Code and Diagrams
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.
- Rule: one screen at a time, one box at a time, tests first.
- MVP → MVVM:
view.showX()becomesstate = 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
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.
- 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.
2. MVC to MVP
Covered in detail in Part 3. In one picture: the Activity splits into a passive view and a presenter.
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.
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.
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.
// 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.
- List everything the view can show (loading, profile, error). That list is
ProfileState. - Turn each presenter method (
onLoadClicked) into anAction. - Turn each
view?.showX()into a new state produced byreduce. - 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.
// 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 → To | What moves | What stays | Effort |
|---|---|---|---|
| MVC → MVP | Decisions leave the Activity; view interface appears | Layouts, repository | Medium (per screen) |
| MVP → MVVM | view.showX() becomes state = X; delete attach/detach | Repository, use cases, layouts | Low (use the bridge) |
| MVVM → MVP | State assignments become view calls | Everything else | Low |
| MVVM → Redux | Methods become actions; assignments move to reduce() | ViewModel as store, view collection | Low to medium |
| Redux → MVVM | Inline each reducer branch into a method | State class | Low |
| MVP/MVVM → VIPER | Extract interactor and router | View, repository | Medium |
| VIPER → MVVM | Presenter merges into ViewModel; interactor stays as use case | Interactor, entity, repository | Low |
Tap a row to open its diagram and a before/after code example.
MVC → MVPpull logic out of the Activity
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
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
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
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
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
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
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
- Pin: write tests around the screen's current behavior (the same test shape from Part 8).
- Extract: pull the business rule into a use case such as
LoadProfile. Both old and new logic will call it. - Swap: change the logic box (presenter to ViewModel, ViewModel to store). Use a bridge if the change is big.
- Clean: remove the bridge and the unused interface; run the same tests; ship.
- 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.