App Architecture with MVVM
Reviewed & published by Brayan K
A clean architecture keeps apps maintainable as they grow. In this final lesson you'll structure a SwiftUI app with MVVM — separating the Model, View, and ViewModel — using ObservableObject view models for separation of concerns and easy testing.
Part of the free Swift course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • What MVVM is: Model, View, and ViewModel roles
- • Keep the Model pure: data and domain rules, no UI
- • Build an ObservableObject ViewModel with @Published state
- • Keep the View thin, bound to ViewModel state
- • Achieve clear separation of concerns
- • Unit-test the ViewModel without any UI
📊 The Three Roles
| Layer | Owns | Knows about |
|---|---|---|
| Model | Data & domain rules | Nothing about UI |
| ViewModel | Presentation logic & state | The Model |
| View | Layout & user actions | The ViewModel |
1️⃣ The Model
The Model is pure data and domain rules. It imports no UI framework, so it can be reused and tested anywhere.
import Foundation
// MODEL: pure data and domain rules. No UI, no SwiftUI imports.
struct Todo: Identifiable {
let id = UUID()
var title: String
var isDone: Bool
}
// A small domain rule lives with the model.
extension Array where Element == Todo {
var remainingCount: Int { filter { !$0.isDone }.count }
}2️⃣ The ViewModel
The ViewModel is an ObservableObject that prepares model data for the view and holds presentation logic. Its @Published state drives the UI.
import Foundation
import Combine
// VIEWMODEL: prepares data for the view and holds presentation logic.
// It is an ObservableObject so the view can observe its @Published state.
final class TodoListViewModel: ObservableObject {
@Published private(set) var todos: [Todo] = []
// Presentation-friendly value the view can bind to directly.
var summary: String { "\(todos.remainingCount) left" }
func add(_ title: String) {
todos.append(Todo(title: title, isDone: false))
}
func toggle(_ todo: Todo) {
guard let i = todos.firstIndex(where: { $0.id == todo.id }) else { return }
todos[i].isDone.toggle()
}
}Your turn. Fill in the protocol and the publishing wrapper.
// 🎯 YOUR TURN — replace each ___ then run in Xcode.
import Foundation
import Combine
final class CounterViewModel: ___ { // 👉 protocol that lets views observe it
___ private(set) var count = 0 // 👉 wrapper that publishes changes
func increment() { count += 1 }
}
// In the view:
// @StateObject private var model = CounterViewModel()
// Text("\(model.count)")
// Button("+") { model.increment() }
// ✅ Tapping + updates the published count and refreshes the view.3️⃣ The View (and Testing the ViewModel)
The View stays thin: it shows ViewModel state and forwards actions. Because the ViewModel is UI-independent, you can unit-test its logic directly with XCTest.
import SwiftUI
// VIEW: displays the ViewModel's state and forwards user actions.
// It owns the view model with @StateObject and stays thin.
struct TodoListView: View {
@StateObject private var model = TodoListViewModel()
var body: some View {
VStack {
Text(model.summary) // bound to view-model state
List(model.todos) { todo in
Button(todo.title) { model.toggle(todo) } // forward an action
}
Button("Add") { model.add("New task") }
}
}
}import XCTest
// Because the ViewModel has no UI dependency, you can test it directly.
final class TodoListViewModelTests: XCTestCase {
func testAddIncreasesCount() {
let model = TodoListViewModel()
model.add("Buy milk")
XCTAssertEqual(model.todos.count, 1)
}
func testToggleMarksDone() {
let model = TodoListViewModel()
model.add("Read")
model.toggle(model.todos[0])
XCTAssertTrue(model.todos[0].isDone)
}
}Now you try. Fill in the wrapper that owns the view model and the method call.
// 🎯 YOUR TURN — wire the view to the view model.
import SwiftUI
struct CounterView: View {
___ private var model = CounterViewModel() // 👉 wrapper to create & own the VM
var body: some View {
VStack {
Text("\(model.count)")
Button("Add") { model.___() } // 👉 call the VM's method
}
}
}
// ✅ Tapping Add increments the count shown.Pro Tips
- 💡 Keep the Model UI-free. If it imports SwiftUI, it's drifting out of its lane.
- 💡 Own the view model with @StateObject when the view creates it; use @ObservedObject when it's passed in.
- 💡 Inject services via protocols so the ViewModel can be tested with mocks.
- 💡 Push logic down. If a View grows an if tangle, move it into the ViewModel.
Common Errors (and the fix)
- View model resets unexpectedly — you used @ObservedObject for a VM the view creates; use @StateObject.
- View doesn't update — the changing property isn't marked @Published.
- Logic stuck in the View — move calculations and decisions into the ViewModel.
- Model imports SwiftUI — keep the Model UI-free so it stays reusable and testable.
📋 Quick Reference: MVVM Recap
| Layer | Responsibility | Key tools | Tested how |
|---|---|---|---|
| Model | Data & domain rules | struct, enum | plain unit tests |
| ViewModel | Presentation logic & state | ObservableObject, @Published | XCTest, no UI |
| View | Layout & user actions | @StateObject, body | UI / preview |
| Binding | View observes VM | @StateObject / @ObservedObject | auto-refresh |
Mini-Challenge: Weather in MVVM
Structure a small weather screen as Model, ViewModel, and View, then test the ViewModel — the perfect capstone for the track.
// 🎯 MINI-CHALLENGE: A weather screen in MVVM
// 1. MODEL: struct Weather { let city: String; let temp: Int }
// 2. VIEWMODEL: final class WeatherViewModel: ObservableObject with
// @Published var weather: Weather? and a func load() that sets a value.
// Add a computed 'displayText' that formats the weather for the view.
// 3. VIEW: a SwiftUI view with @StateObject var model = WeatherViewModel()
// that shows model.displayText and a "Load" button calling model.load().
// 4. Write one XCTest asserting displayText is correct after load().
//
// Notice: the View has almost no logic, and the ViewModel is fully testable.
// your code here🎉 Track Complete!
- ✅ MVVM = Model (data), View (UI), ViewModel (presentation logic)
- ✅ The Model stays UI-free; the View stays thin
- ✅ The ViewModel is an ObservableObject with @Published state
- ✅ Views own view models with @StateObject
- ✅ Separation of concerns makes the ViewModel easy to unit-test
- ✅ Congratulations — you've finished the Building iOS Apps with SwiftUI track!
Practice quiz
What do the three letters in MVVM stand for?
- Model-View-ViewModel
- Model-Value-View-Module
- Main-View-Model-Manager
- Module-View-ViewMaker
Answer: Model-View-ViewModel. MVVM is Model, View, and ViewModel.
What is the Model's responsibility?
- Drawing the UI
- Representing the app's data and business rules
- Handling taps
- Storing view state only
Answer: Representing the app's data and business rules. The Model holds data and domain logic, independent of the UI.
What does the View do in MVVM?
- Stores the database
- Displays state and forwards user actions
- Performs networking
- Owns business rules
Answer: Displays state and forwards user actions. The View shows what the ViewModel exposes and relays user input.
What is the ViewModel's job?
- Render pixels
- Prepare data for the view and hold presentation logic
- Define the storage schema
- Replace the Model entirely
Answer: Prepare data for the view and hold presentation logic. The ViewModel transforms model data into view-ready state and handles presentation logic.
Which protocol do SwiftUI view models commonly adopt?
- Codable
- Hashable
- ObservableObject
- Sendable
Answer: ObservableObject. ObservableObject lets a view model publish changes that the view observes.
Inside an ObservableObject, which wrapper publishes a property's changes?
- @State
- @Published
- @Binding
- @Environment
Answer: @Published. @Published broadcasts changes so observing views refresh.
Which wrapper does a SwiftUI view use to observe a view model it creates and owns?
- @State
- @StateObject
- @Published
- @FetchRequest
Answer: @StateObject. @StateObject creates and owns an ObservableObject for the view's lifetime.
A key benefit of MVVM is...
- Fewer files always
- No need for models
- Faster compile times guaranteed
- Separation of concerns and easier testing of logic
Answer: Separation of concerns and easier testing of logic. MVVM separates UI from logic, making the logic easy to test in isolation.
Why is a ViewModel easy to unit test?
- It holds plain logic with no UI dependency
- It imports SwiftUI heavily
- It runs on the GPU
- It cannot be tested
Answer: It holds plain logic with no UI dependency. Because a ViewModel is UI-independent, you can test its logic directly without rendering views.
In MVVM, the View should ideally contain...
- All business logic
- The data model
- Little logic — mostly layout bound to ViewModel state
- The networking code
Answer: Little logic — mostly layout bound to ViewModel state. Views stay thin, delegating logic to the ViewModel for testability and clarity.
Continue this course
- Previous: Swift Package Manager
- Next: Custom Property Wrappers — Build your own @propertyWrapper with wrappedValue and projectedValue
Frequently asked questions
What is MVVM and why use it?
MVVM stands for Model, View, ViewModel. It splits an app into three roles: the Model holds data and business rules, the View displays things and forwards user actions, and the ViewModel prepares model data for the view and contains presentation logic. The payoff is separation of concerns: the UI stays thin, the logic is centralized, and that logic becomes easy to test in isolation.
What exactly goes in the ViewModel?
The ViewModel owns presentation logic and view-ready state. It transforms raw model data into formatted values the view can bind to, handles user actions the view forwards, and coordinates with services like networking or persistence. In SwiftUI it is usually an ObservableObject whose @Published properties drive the view, while keeping no direct knowledge of SwiftUI view types.
Why should the View stay thin?
A thin View is mostly layout that binds to ViewModel state and relays user input, with little or no business logic. This keeps the parts that are hard to test, the UI, simple and stable, while the testable logic lives in the ViewModel. It also makes the View easier to read and to redesign without touching behaviour.
How do ObservableObject and @Published fit in?
A ViewModel conforms to ObservableObject so SwiftUI views can observe it. Marking its properties @Published makes each change broadcast to observers, so any view watching the ViewModel refreshes automatically. The view typically creates and owns its ViewModel with @StateObject, or receives a shared one with @ObservedObject, and reads the published state in its body.
Why is MVVM good for testability?
Because the ViewModel contains the app's presentation logic but has no dependency on SwiftUI view rendering, you can instantiate it directly in a unit test, call its methods, and assert on its published state. There is no need to spin up a UI. Combined with injecting dependencies through protocols, this lets you test behaviour quickly, reliably, and offline.