There's one iOS architecture question I keep hearing:
"Do you use MVVM?"
And somehow, it often sounds like the real question is:
"Have you solved architecture?"
Not really.
MVVM is useful. I've shipped plenty of MVVM code. I'm not here to start a holy war between MVC, MVVM, VIPER, TCA, Redux, or whatever architecture acronym becomes popular next.
But my take is simple:
MVVM is not architecture magic.
It's a design pattern. A useful one, but still a pattern. It gives you a ViewModel. That's mostly the gift.
It doesn't decide where side effects should live. It doesn't solve navigation. It doesn't define dependency boundaries. And it doesn't stop your team from growing a 900-line HomeViewModel.
From Massive View Controller to Massive ViewModel
The classic iOS joke is what MVC really stands for:
Massive View Controller
And we all know whose sample code first taught us to cram half the universe into one UIViewController.
Looking at you, Apple.
To be fair, those samples exist to demo one API, not to ship. But a whole generation of us copy-pasted that shape, shipped it anyway, and regretted it about two years later. The Massive View Controller was never our bug. It was the starter template.
The pattern is familiar. Everything goes into the view controller — UI, networking, validation, analytics, navigation, loading states, random flags:
final class HomeViewController: UIViewController {
private var user: User?
private let api = UserAPI()
@IBAction func didTapRefresh() {
isLoading = true; updateLoadingUI()
api.fetchUser { result in
self.isLoading = false; self.updateLoadingUI()
switch result {
case let .success(user): self.user = user; self.updateUserUI(user)
case let .failure(error): self.showError(error)
}
}
}
@IBAction func didTapProfile() {
navigationController?.pushViewController(
ProfileViewController(user: user), animated: true)
}
}
This isn't evil code. That's exactly why it's dangerous.
It starts normal. Then one more feature. One more state. One more API call. Six months later nobody wants to open the file.
MVVM moves some of that into a ViewModel. Good. But if there are still no real boundaries, you don't kill the monster. You just move it:
final class HomeViewModel {
private let api: UserAPI
private let router: HomeRouter
private let cache: UserCache
var isLoading = false
var user: User?
var errorMessage: String?
func onAppear() { ... }
func refresh() { ... }
func didTapProfile() { ... }
func reloadEverything() { ... }
}
Congratulations. You invented the Massive ViewModel.
Same monster. New name badge.
"Just add a Coordinator" isn't the whole answer
The usual MVVM defense is:
"Then add a Coordinator."
Sure. Coordinators can help. Then someone adds UseCases. Then Repositories. Then Services. Then Presenters. Then Routers. Then Factories.
At some point you're not reading code anymore. You're reading an org chart.
And here's my first problem with that argument: you can add those layers to MVC too. This isn't something the MVVM era invented. Back in the Objective-C days people were already pulling data sources, delegates, and flow objects out of view controllers — objc.io wrote Lighter View Controllers about it years ago. I remember that one well, partly because I translated it back then. Small world. Small trauma.
My second problem is bigger: abstraction is very easy to abuse.
It often solves the "big file" problem by creating a "where the hell did the logic go?" problem. The file gets smaller — and readability gets worse. Instead of one large file, you now have:
HomeViewController HomeRouter HomeService
HomeViewModel HomeUseCase HomeStatePlanner
HomeCoordinator HomeRepository HomeFlowSomething
HomePresenter
And a new developer has to ask:
Is navigation in the Coordinator or the Router?
Is formatting in the Presenter or the ViewModel?
Is business logic in the UseCase or the Service?
What exactly is a Planner, and who invited it?
That's my problem with over-abstraction. It can look clean from far away. But the moment you actually need to change something, you spend half your time playing hide-and-seek with the code.
A good developer can write clean MVC. A weak team can write disastrous MVVM. The folder name is not the architecture.
The real question is:
Can a normal developer tell where things are supposed to go?
Why I like TCA-style flow
That's where I started to appreciate TCA-style architecture.
TCA isn't magic either. It has a cost, it can be verbose, the learning curve is real, and yes — reducers can get large.
But the largeness is structured. A feature has a clear shape:
struct HomeFeature {
struct State {
var isLoading = false
var user: User?
var errorMessage: String?
}
enum Action {
case onAppear, refreshTapped
case userResponse(Result<User, Error>)
case profileTapped
}
func reduce(state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear, .refreshTapped:
state.isLoading = true
return .run { send in
await send(.userResponse(userClient.fetchUser()))
}
case let .userResponse(.success(user)):
state.isLoading = false; state.user = user; return .none
case let .userResponse(.failure(error)):
state.isLoading = false; state.errorMessage = error.localizedDescription; return .none
case .profileTapped:
return .none
}
}
}
Yes, that switch can get big. But it's a known kind of big. You know where the state is. You know what actions can happen. You know where side effects are triggered. You know where the feature logic lives.
That's very different from opening a Massive ViewController and finding API calls, navigation, global state, callbacks, delegates, notification observers, and mystery flags all mixed together.
The problem was never file size. The problem is not knowing what's allowed inside the file.
TCA doesn't remove complexity. It gives complexity a fixed address.
The real gain: readability
This became very clear when I was working on a recent project, MoveMentor, where I tried a TCA-inspired unidirectional flow in the iOS codebase. The goal wasn't to look clever — I don't care how impressive an architecture looks in a diagram. The goal was readability.
The result was obvious.
Later, an Android developer joined. He didn't really know Swift. He'd never worked deeply with iOS. By rights, an iOS feature should have been a wall.
It wasn't. He could open a feature and quickly understand:
State → what this screen can be
Action → what can happen here
Reducer → how the feature responds
Effect → where async work happens
He could follow exactly what happened after a user tapped something — in a language and on a platform he didn't know.
That was huge.
For me, that's the real proof. Not theory, not architecture philosophy. Just onboarding speed.
Throw a random MVC/MVVM/Coordinator codebase at someone new and the architecture doesn't explain the code — they still have to learn where this team decided to put things. With this structure, the code explained itself. Not perfectly. Nothing is. But much better.
That's the highest bar I know:
Can the code be clearer than the documentation?
Here, it was.
MVVM is fine. It just isn't enough by itself.
So no — I'm not saying MVVM is bad.
MVVM is fine. For many apps it's enough. With strong developers and good discipline it works very well.
But MVVM doesn't solve architecture. It gives you a place to put things. It doesn't guarantee anyone puts the right things there.
And honestly, if the code is going to be badly structured anyway, I might even prefer plain MVC over fake-clean MVVM. At least MVC is obvious — a junior has seen Apple's sample code and knows where to start reading. It may be messy, but the mess is visible. Bad MVVM with five abstract layers can be worse: it looks professional while hiding the confusion. That's the dangerous kind of mess.
So "Do you know MVVM?" was never a very useful architecture question. Better ones:
How do you stop your ViewModel from becoming the next Massive ViewController?
Where do side effects live?
How does state move through the app?
Can a new developer follow a feature without asking five people?
That's where architecture actually starts.
My preference now is simple. I like architectures that raise the floor — not because they make great developers greater, but because they make ordinary codebases harder to ruin.