From fifteen years of solid MVC apps to the clear, obvious choice of MVVM for GeoLog in the age of SwiftUI.
What MVVM actually is, where the Controller went, and why the switch felt obvious.
My friend has been writing MVC apps since before most of today’s iOS developers had their first smartphone. Good apps. Shipping apps. Apps that handle real complexity without falling apart. So when I told him that GeoLog — my photo location scouting app — is built on MVVM, he gave me the look.
You know the one. The slight squint. The almost-imperceptible pause before he said, “isn’t that just a Controller with a different name?”
I didn’t have a great answer on the spot. I mumbled something about data binding and testability and watched his expression settle into polite skepticism. My friend wasn’t wrong to push back — MVC works. It’s proven. Fifteen years of shipping software doesn’t lie.
But I knew there was a real answer in there somewhere. So I went home and tried to put it together properly. What follows is what I came up with — what MVVM actually is, why it exists, where the Controller went, and why SwiftUI made the switch feel less like a choice and more like the obvious path forward.
Table of Contents
A Quick Refresher on MVC
MVC — Model View Controller — has been the dominant pattern in UI development for decades, and for good reason. It divides an application into three clear responsibilities:
- Model — your data and business logic. In an iOS app this might be a SwiftData model, a struct representing a location, or a class that fetches data from an API. The Model doesn’t know anything about the UI.
- View — what the user sees. Buttons, labels, lists, maps. The View doesn’t know anything about where the data came from.
- Controller — the glue. It sits between the Model and the View, responds to user input, updates the Model, and tells the View what to display.
On paper it’s elegant. In practice, on iOS, it had a problem.
Apple’s UIViewController was doing all three jobs simultaneously — responding to button taps, formatting data for display, managing navigation, handling lifecycle events, persisting state. The Controller wasn’t just the glue anymore. It was the glue, the scaffolding, and half the building.
Developers started calling it something else: Massive View Controller.
That name stuck because it was accurate. A class that knew too much, did too much, and was nearly impossible to test in isolation because it was so tangled up with the View it was managing. MVC doesn’t cause the problem, but it doesn’t prevent it either. Nothing in the pattern stops the Controller from becoming a thousand-line catch-all for everything that doesn’t obviously belong somewhere else.
That’s the crack in the foundation that MVVM was designed to fix.
So What’s Wrong With It?
Nothing, actually — and that’s important to say up front. MVC is not broken. Fifteen years of shipping software is proof enough of that. The pattern works, and it works well.
The problem isn’t MVC. The problem is what happens to the Controller as an app grows.
On iOS, Apple’s UIViewController was expected to be the Controller. For a simple screen it was fine. But as screens got more complex, the Controller started accumulating responsibilities that didn’t clearly belong anywhere else — formatting data for display, managing navigation, handling keyboard events, persisting state, responding to lifecycle callbacks. None of those are wrong to put in a Controller. They’re just… there, because there was nowhere else obvious to put them.
The fix isn’t discipline. Careful organization helps, but nothing in MVC prevents the Controller from becoming a thousand-line catch-all. MVVM tries to fix that at the architectural level — by making the right structure the obvious one rather than the disciplined one.
One more thing worth knowing before we get into MVVM: it didn’t come from Apple. It originated at Microsoft in 2005, designed for WPF — Windows Presentation Foundation. When Apple shipped SwiftUI in 2019 they never used the word MVVM, but they built a framework that makes it feel completely native.
Enter MVVM — Same Idea, Better Boundaries
MVVM keeps the same three-layer idea as MVC but draws the boundaries differently — and one of those boundaries turns out to make all the difference.
The three layers are:
- Model — same as MVC. Your data, your business logic, your persistence. It knows nothing about the UI and doesn’t care how its data gets displayed.
- View — also familiar. What the user sees and interacts with. In SwiftUI this is your
Viewstruct — buttons, lists, pickers, text fields. - ViewModel — this is the new piece. It sits between the Model and the View, exposes data as observable properties, handles user actions, and formats data for display. Critically, it has no reference to the View whatsoever.
That last point is where the real difference lives. In MVC the Controller knew about both the Model and the View — it was the bridge between them, which meant it was coupled to both. The ViewModel knows only about the Model. The View watches the ViewModel and updates itself automatically when something changes.
That one-way relationship is called data binding, and it’s what MVVM was invented to support. The View observes the ViewModel. The ViewModel never looks back.
The practical result: your ViewModel is a plain Swift class with no UIKit or SwiftUI imports. You can instantiate it in a unit test, feed it data, and verify its behavior without a screen, a simulator, or a running app. That’s nearly impossible with a traditional UIViewController because the Controller and the View are so entangled.
MVVM didn’t originate in Apple’s world — it came from Microsoft in 2005, designed for WPF where automatic data binding between UI and state was a first-class feature. Apple’s UIKit era was firmly MVC. But when SwiftUI arrived in 2019, Apple built binding and observable state directly into the framework — @Observable, @State, @Bindable — without ever officially calling it MVVM. The pattern and the framework just happened to fit each other perfectly.
Where Did the Controller Go?
It’s a fair question. You go from three letters to four, but one of them is new — so what happened to the C?
The honest answer is that the Controller didn’t disappear. Its responsibilities got redistributed to places that make more sense.
In MVC the Controller was doing too many jobs:
- Responding to user input
- Updating the Model
- Formatting data for display
- Telling the View what to render
- Managing navigation
MVVM splits those responsibilities apart and sends them to better homes:
- Responding to user input and updating the Model → the ViewModel handles this. A button tap calls a method on the ViewModel, which updates its own state and tells the Model to persist the change.
- Formatting data for display → also the ViewModel. This is presentation logic — turning a raw enum value into a human-readable label, deciding which color to show, computing a derived string. It belongs in the ViewModel, not scattered across the View.
- Telling the View what to render → the framework handles this automatically via data binding. The View observes the ViewModel’s properties and updates itself. Nobody has to manually push changes.
- Managing navigation → SwiftUI handles this declaratively. Navigation is driven by state, not by a Controller telling a View to present another View.
What you’re left with is a ViewModel that owns presentation logic and user actions, a Model that owns data and business logic, and a View that just reflects what the ViewModel exposes. Clean, testable, one clear job each.
The Controller didn’t die. It got a proper burial — and its responsibilities went to better homes.
A Real Example: Distance Units in GeoLog
Enough theory. Here’s how MVVM actually looks in a shipping SwiftUI app.
GeoLog lets users choose between imperial and metric distance units — miles and feet, or kilometers and meters. It’s a simple setting, but it touches all three layers cleanly and makes a good illustration of where each responsibility lives.
The Model
The Model owns the data and the business logic. It stores the user’s preference and handles the actual unit conversion — that’s a pure calculation with no UI concern whatsoever.
enum DistanceUnit: String, Codable {
case imperial
case metric
}
@Model
class SettingsModel {
var distanceUnit: DistanceUnit = .imperial
func convert(_ meters: Double) -> Double {
switch distanceUnit {
case .imperial: return meters * 3.28084 // feet
case .metric: return meters
}
}
}
The Model doesn’t know what a Picker is. It doesn’t know what color anything is. It just holds the truth and does the math.
The ViewModel
The ViewModel sits between the Model and the View. It exposes the setting as a bindable property, handles the user’s action, and formats the data for display — the label the user actually sees in the UI.
@Observable
class SettingsViewModel {
private var model: SettingsModel
var distanceUnit: DistanceUnit {
get { model.distanceUnit }
set { model.distanceUnit = newValue }
}
// Presentation logic — formatting for display
var distanceUnitLabel: String {
switch model.distanceUnit {
case .imperial: return "Miles & Feet"
case .metric: return "Kilometers & Meters"
}
}
init(model: SettingsModel) {
self.model = model
}
}
Notice what’s missing: no import SwiftUI, no reference to any View, no layout logic. The ViewModel can be instantiated in a unit test with no simulator required.
The View
The View is the simplest layer of all. It binds to the ViewModel, displays what the ViewModel exposes, and fires actions back to it. It makes no decisions about data — it just reflects state.
struct SettingsView: View {
@State private var viewModel: SettingsViewModel
var body: some View {
Form {
Section("Units") {
Picker("Distance", selection: $viewModel.distanceUnit) {
Text("Miles & Feet").tag(DistanceUnit.imperial)
Text("Kilometers & Meters").tag(DistanceUnit.metric)
}
}
}
}
}
The View doesn’t know how conversion works. It doesn’t format labels. It doesn’t touch persistence. It binds to the ViewModel and gets out of the way.
That’s the pattern in full. Three layers, one clear job each, no overlap.
The Rule of Thumb
After living with MVVM across several versions of GeoLog, one simple test has become second nature for deciding where any piece of logic belongs:
Does it touch the UI or format data for display? → ViewModel
Is it a pure calculation or business rule? → Model
Is it just showing state and responding to user input? → View
That’s it. Three questions, three answers.
In practice it looks like this:
- The haversine distance calculation between two GPS coordinates? Model — it’s pure math, it has no opinion about how the result gets displayed.
- Converting that distance to
"1.3 miles"for a label? ViewModel — that’s presentation logic, formatting a raw value into something the UI can show. - The
Textthat displays"1.3 miles"on screen? View — it just reflects what the ViewModel already decided.
The boundary that trips people up most often is the Model/ViewModel line. A common mistake is putting formatting logic in the Model — having it return "1.3 miles" instead of 1.3 and .imperial. The moment the Model knows about display format it’s doing the ViewModel’s job, and you’ve lost the clean separation that makes testing straightforward.
The other common mistake is letting the View make decisions. If you find an if statement in a View body that’s choosing between two different strings or colors based on data — that logic belongs in the ViewModel as a computed property. The View should receive an answer, not compute one.
When in doubt, ask: could I test this without a screen? If yes, it probably belongs in the Model or ViewModel. If no — if it only makes sense in the context of a running UI — it belongs in the View.
My Friend’s Verdict
I sent my friend a draft of this article. His response was what I expected — not a full conversion, but something more honest than that.
“Okay,” he said. “I see why SwiftUI pushed you that direction.”
That’s the right answer actually. MVVM isn’t universally superior to MVC — it’s the right pattern for a data-binding framework. UIKit didn’t give you binding natively, so MVC with discipline worked fine. SwiftUI does give you binding natively, so MVVM stops being a choice and starts being the obvious path. The framework and the pattern just happen to fit each other perfectly.
The tangled arrows on the left side of the header image above aren’t an indictment of my friend’s fifteen years of work. They’re an honest picture of what happens when one layer — the Controller — is asked to do too much. The clean boundaries on the right aren’t magic. They’re what you get when the framework handles binding automatically and the ViewModel is free to do exactly one job.
My friend still writes MVC apps. Good ones. Shipping ones.
He’s also started asking me questions about SwiftUI.
Closing Thoughts
MVVM isn’t a silver bullet. It adds a layer — the ViewModel — and that layer has to be written, maintained, and tested. For a genuinely simple screen with no business logic, that overhead might not be worth it. Not every View needs a ViewModel.
But for an app with real complexity — settings that affect calculations, data that gets formatted multiple ways, logic that needs to be verified without running a simulator — the boundaries MVVM enforces pay for themselves quickly. The ViewModel becomes the place where the interesting work happens, and the View becomes almost boring. That’s the goal.
The bigger point is this: SwiftUI didn’t just give iOS developers a new way to build UIs. It shifted the architecture underneath. The binding system, the observable state, the declarative layout — all of it points toward MVVM whether you call it that or not. Fighting the pattern means fighting the framework. Working with it means your code ends up cleaner almost by default.
My friend builds great software. He always has. The tools he’s used to work, and they’ll keep working. But the iOS world has moved, and SwiftUI moved it deliberately in a direction that makes MVVM the natural home for any app worth building.
If you’re writing SwiftUI and you haven’t thought about where your logic lives — whether it’s in the View, the ViewModel, or the Model — this is the moment to think about it. The distance unit example in Section 6 is a good place to start. Pick one setting in your own app and draw the three lines. It gets easier from there.
And if your MVC friend gives you the look — send them this link.



Leave a Reply