MVVM, VIPER, TCA — how they scale in practice
A practitioner look at how MVVM, VIPER, and TCA tend to scale in iOS teams, and why team habits matter more than architecture branding.
I’ve worked in and studied iOS codebases built with MVC, MVVM, VIPER, Clean Architecture, and TCA. Some of those apps had five screens. Some had over a hundred. Some were maintained by a single developer. Some had larger cross-functional teams behind them.
This is not a formal survey of iOS architecture patterns. It is a practitioner view based on the tradeoffs I have seen repeatedly in real teams and real codebases.
The conclusion I keep coming back to is simple: the best architecture is the one your team can apply consistently under pressure. Not the one with the cleanest diagram. Not the one from the conference talk. The one that still makes sense on a Friday afternoon, when a critical bug lands and someone needs to fix it without destabilizing the rest of the app.
That does not mean “anything works.” Some architectures make consistency much easier than others. But the tradeoff is usually between flexibility, explicitness, and the amount of structure your team is realistically willing to maintain.
Where MVC Starts to Strain
Apple’s MVC gets a bad reputation, and some of the criticism is deserved, but often for the wrong reason. People say “Massive View Controller” as if the problem were line count. It usually is not. You can absolutely find 2,000-line view controllers that are ugly but workable, with clean // MARK: sections and decent internal organization.
The real problem with MVC is implicit dependencies. A UIViewController can reach into almost anything. It can hold a reference to the network layer, to another view controller, to app-wide state, and to persistence objects directly. The pattern itself does not put much friction in the way.
When you’re the only developer, that’s fine. You know where everything is because you put it there. When you have five developers, three of them will wire things up differently. Some ViewControllers call the network manager directly. Others go through a service layer. And somewhere there’s one inexplicable delegate chain spanning four ViewControllers to avoid importing the networking module.
That is why MVC often struggles as an app grows. It gives you a starting point, but not much guidance once multiple developers are touching the same feature set under delivery pressure.
MVVM: A Pragmatic Default
MVVM is one of the most common architecture styles in modern iOS development, and for good reason. It addresses the main weakness of MVC by giving business logic a home that is not the view controller.
A well-structured MVVM feature looks like this:
// ViewModel — owns the business logic
@Observable
class ProfileViewModel {
private(set) var profile: Profile?
private(set) var isLoading = false
private(set) var error: String?
private let profileService: ProfileServiceProtocol
init(profileService: ProfileServiceProtocol = ProfileService()) {
self.profileService = profileService
}
func loadProfile(for userId: String) async {
isLoading = true
error = nil
do {
profile = try await profileService.fetchProfile(userId: userId)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
// View — only renders what the ViewModel provides
struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
let userId: String
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let profile = viewModel.profile {
ProfileContent(profile: profile)
} else if let error = viewModel.error {
ErrorView(message: error)
}
}
.task { await viewModel.loadProfile(for: userId) }
}
}
This is clean. The view model is testable. The view is mostly rendering state. If a bug appears, you at least know roughly where to start looking.
Where MVVM Gets Ambiguous
The tension in MVVM usually shows up when features need to communicate with each other.
Imagine you have a SettingsViewModel where the user changes their display name, and a ProfileViewModel that displays the name. How does the profile update when the setting changes?
Common solutions:
- NotificationCenter: Works, but it’s invisible coupling. Nothing in the code tells you that
ProfileViewModeldepends on settings changes. - Shared observable state: Both ViewModels reference the same
UserStore. This works until three other ViewModels also reference it, and now you’ve recreated the God Object problem. - Delegate/Closure callbacks: The parent coordinator passes a closure from one ViewModel to another. This works for simple cases but creates tangled dependency chains in complex apps.
- Combine publishers: A common reactive solution, but chaining publishers across ViewModels can make ownership harder to reason about and the data flow harder to trace in a debugger.
None of these solutions are inherently wrong. The issue is that MVVM does not tell you which one should be the default. In many codebases, you end up seeing all four patterns in the same app. That is the scalability problem: not that MVVM cannot handle complexity, but that it leaves too much room for teams to solve the same problem four different ways.
MVVM with Coordinators
Adding coordinators to MVVM can make navigation much clearer. View models no longer need to know about other screens directly. They emit events, and a coordinator decides what to present next.
protocol ProfileCoordinating: AnyObject {
func didTapEditProfile()
func didTapSettings()
func didTapLogout()
}
class ProfileCoordinator: ProfileCoordinating {
private let navigationController: UINavigationController
func didTapEditProfile() {
let editVM = EditProfileViewModel(profileService: profileService)
let editVC = EditProfileViewController(viewModel: editVM)
navigationController.pushViewController(editVC, animated: true)
}
// ...
}
In SwiftUI, coordinators are often less central because NavigationStack handles a lot declaratively. In UIKit-heavy codebases, or in apps with multiple overlapping flows, they still earn their keep.
VIPER: High Structure, High Ceremony
VIPER has a polarizing reputation. The ideas behind it are disciplined. The day-to-day experience can be either reassuring or exhausting, depending on the team.
VIPER takes the Single Responsibility Principle to its absolute extreme. For every feature, you create:
- View: Renders UI, nothing else
- Interactor: Business logic, no UI knowledge
- Presenter: Transforms Interactor output for the View
- Entity: Data models
- Router/Wireframe: Navigation
The protocol contracts look something like this:
protocol SettingsViewProtocol: AnyObject {
func displaySettings(_ viewModel: SettingsDisplayModel)
func showLoadingIndicator()
func showError(_ message: String)
}
protocol SettingsPresenterProtocol {
func viewDidLoad()
func didToggleNotifications(_ enabled: Bool)
func didTapPrivacyPolicy()
}
protocol SettingsInteractorProtocol {
func fetchSettings()
func updateNotificationPreference(_ enabled: Bool)
}
protocol SettingsRouterProtocol {
func navigateToPrivacyPolicy()
}
That is four protocols before much feature logic exists. Add the concrete implementations and even a simple screen can turn into a small module.
Where VIPER Fits Best
Despite the verbosity, VIPER can work well in the right environment. Think of a large regulated product with many engineers shipping in parallel. In that setting, rigid boundaries can be more helpful than annoying:
- The developer building the Interactor didn’t need to know what the UI looked like
- The developer building the View didn’t need to understand the business rules
- The router logic was centralized and predictable
- Code reviews were fast because every module looked identical
In that context, the extra ceremony can be a feature rather than a bug. Every file is small, focused, and easier to review in isolation.
Where VIPER Gets Expensive
VIPER is often overkill for smaller teams or products that need to move quickly. In those cases, the coordination cost can outweigh the benefits. You spend more time creating structure than delivering behavior, and the original “where does this code go?” question becomes “which layer owns this tiny decision?”
TCA: Explicit Composition and Constraints
I’ve written extensively about TCA in a dedicated post, so I’ll focus on the scaling aspects here.
TCA’s biggest advantage in larger apps is composition. Every feature is a reducer: state goes in, an action arrives, and you decide how state changes plus what effects should run. Larger features compose smaller ones:
@Reducer
struct AppFeature {
struct State: Equatable {
var tabs = TabsFeature.State()
var auth = AuthFeature.State()
}
enum Action {
case tabs(TabsFeature.Action)
case auth(AuthFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.tabs, action: \.tabs) {
TabsFeature()
}
Scope(state: \.auth, action: \.auth) {
AuthFeature()
}
Reduce { state, action in
// Cross-feature logic goes here
switch action {
case .auth(.logoutCompleted):
state.tabs = TabsFeature.State() // Reset tabs on logout
return .none
default:
return .none
}
}
}
}
That Reduce at the bottom is where cross-feature communication lives, and it is explicit and co-located. You can read that code and see exactly what happens when logoutCompleted fires. That clarity is a real strength in larger systems.
Testing in TCA
This is where TCA stands out most for me. TCA’s TestStore supports a very exhaustive style of reducer testing: you assert state changes and effects precisely, and the framework makes it hard to be vague about behavior.
@Test
func logout_resetsTabsState() async {
let store = TestStore(
initialState: AppFeature.State(
tabs: TabsFeature.State(selectedTab: .settings)
)
) {
AppFeature()
}
await store.send(.auth(.logoutCompleted)) {
$0.tabs = TabsFeature.State() // tabs reset to initial state
}
}
If the reducer changes state you did not assert, the test fails. If it emits an effect you did not account for, the test fails. At the reducer level, that catches regressions with a level of precision I have found very valuable.
A Practical Way to Choose
I do not think there is a universal winner here. What I trust more is a short set of questions:
- Does this architecture make state ownership obvious?
- Does it give the team one clear place for business logic?
- Does it reduce hidden coupling, especially across features?
- Can the team test it without heroic effort?
- Can the team apply it consistently six months from now, not just this week?
If the answer to most of those is yes, you are probably in a good place.
My bias is toward the least complicated architecture that still creates useful constraints. That usually matters more than whether the label on the slide says MVC, MVVM, VIPER, or TCA.
The mistake I see most often is choosing an architecture for the version of the app people imagine they will have later, rather than the one they are actually building now. It is usually better to pick something the team can execute well, learn where it starts to hurt, and evolve from there with intention.