How do MVC, MVVM, MVP, VIPER, RIBs and Clean compare, and which would you choose?
Predict first
A view model with an injected fake service is tested with no UI. What does it show for a found and a missing user?
protocol UserService: Sendable { func name(id: Int) async -> String? }
struct FakeService: UserService {
func name(id: Int) async -> String? { id == 1 ? "Asha" : nil }
}
@MainActor final class ProfileViewModel {
private let service: UserService
private(set) var title = "Loading"
init(service: UserService) { self.service = service }
func load(id: Int) async {
title = await service.name(id: id).map { "Hello, \($0)" } ?? "Not found"
}
}
let vm = ProfileViewModel(service: FakeService())
await vm.load(id: 1)
print(vm.title)
await vm.load(id: 2)
print(vm.title)
- Hello, Asha then Not found
- Loading then Loading
- Hello, Asha then Hello, Asha
Mental model. These patterns are floor plans for a house: the open-plan studio (plain MVC) is fine for one person, a few rooms with doors (MVVM) suit a family, and a building with separate wings and fire doors (VIPER, RIBs, modules) suits a hotel. Do not build a hotel for a couple.
All of them separate what to show from how to show it, and differ in how many layers they add. MVC (Apple’s original) has a model, a view and a controller that mediates; in UIKit the view controller tends to absorb everything (“Massive View Controller”) because it owns the view lifecycle, navigation and networking. MVVM adds a view model that holds presentation state and logic (formatted strings, loading and error states, enabled flags) and exposes it to a thin view through bindings, Combine, or @Observable; it is the natural fit for SwiftUI, and the main win is that the view model is a plain Swift object you can unit test without any UI. MVP is similar but the presenter calls the view through a protocol (a passive view), which makes the view trivially fakeable but needs more boilerplate. VIPER splits each screen into View, Interactor, Presenter, Entity and Router: very testable and strict, at the cost of many files per screen. RIBs (Uber) organises the app as a tree of router-interactor-builder units with a clear ownership of business logic, aimed at very large teams. TCA (The Composable Architecture) is a unidirectional framework: state, actions, a reducer and effects, which gives predictable state and strong testing at the price of a framework dependency and a learning curve. Clean Architecture is a layering rule rather than a UI pattern: dependencies point inward (UI → use cases → domain), and the domain knows nothing about frameworks.
How to choose, honestly. The right amount of structure follows team size, app lifetime and testing needs, not fashion. A small app or prototype: plain SwiftUI with @Observable models, or MVC, is fine. A typical product app: MVVM with injected dependencies and a coordinator or router for navigation covers most needs. A large app with many teams: add module boundaries and a stricter pattern (Clean layering, TCA, or RIBs) so that teams can work independently and the rules are mechanically checkable. Warning signs of over-engineering: ten files per screen, protocols with one implementation that never change, layers that only forward calls. Signs of under-engineering: view controllers that fetch, parse, format and navigate, logic that cannot be tested without a simulator. Consistency beats cleverness: pick one pattern per app, write down the rules (where state lives, how screens talk to each other, who navigates), and apply them everywhere, because the cost of a mixed codebase is higher than the cost of a slightly imperfect pattern.
The view model holds the presentation state and is driven entirely by the injected service, so a test needs no screen.
| State lives in | Strength | Cost | |
|---|---|---|---|
| MVC | the controller and model | simple, matches UIKit | view controllers grow large |
| MVVM | a view model per screen | testable presentation logic, fits SwiftUI | binding glue, view models can bloat |
| VIPER | interactor and presenter | strict separation, very testable | many files and boilerplate per screen |
| TCA / unidirectional | one state value with a reducer | predictable, replayable, strong testing | framework dependency, learning curve |
| Clean | domain layer, outward-pointing dependencies | framework-independent core | extra layers and mapping |
Common traps
- Adopting VIPER or RIBs for a small app. The ceremony costs more than the problems it solves.
- A view model that imports UIKit or SwiftUI types. It loses the easy testing that is the whole point.
They might follow up with
- What is the main benefit of a view model? Presentation logic in a plain object that tests without a UI.
- What does Clean Architecture add to MVVM? A dependency rule: the domain and use cases do not depend on UI or frameworks.
Related questions
- 15.1.2 What is unidirectional data flow, and how does a reducer make state changes predictable and testable?
- 15.2.1 What is a coordinator or router, and how does it compare with SwiftUI’s NavigationStack for deciding who owns…
- 8.3.3 How does the Observation framework differ from ObservableObject, and how does tracking work?
What is unidirectional data flow, and how does a reducer make state… →