How do MVC, MVVM, MVP, VIPER, RIBs and Clean compare, and which would you choose?

15.1.1 · L2 · Interview

Reported at: Spotify

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 inStrengthCost
MVCthe controller and modelsimple, matches UIKitview controllers grow large
MVVMa view model per screentestable presentation logic, fits SwiftUIbinding glue, view models can bloat
VIPERinteractor and presenterstrict separation, very testablemany files and boilerplate per screen
TCA / unidirectionalone state value with a reducerpredictable, replayable, strong testingframework dependency, learning curve
Cleandomain layer, outward-pointing dependenciesframework-independent coreextra 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… →