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

📊 The Three Roles

LayerOwnsKnows about
ModelData & domain rulesNothing about UI
ViewModelPresentation logic & stateThe Model
ViewLayout & user actionsThe 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

Common Errors (and the fix)

📋 Quick Reference: MVVM Recap

LayerResponsibilityKey toolsTested how
ModelData & domain rulesstruct, enumplain unit tests
ViewModelPresentation logic & stateObservableObject, @PublishedXCTest, no UI
ViewLayout & user actions@StateObject, bodyUI / preview
BindingView observes VM@StateObject / @ObservedObjectauto-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!

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

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.