Lab 12.1 — Refactor to MVVM

Goal: take a single 400-line UIViewController doing everything (network, parsing, state, presentation) and refactor it cleanly to MVVM with @Observable while preserving behavior.

Time: ~3 hours.

Prereqs: Xcode 16+, iOS 17+ simulator, comfort with async/await.

Setup

Create a new iOS App project, “RefactorMVVM”, SwiftUI lifecycle but with a UIKit screen via UIViewControllerRepresentable. Drop in the following starter ArticlesViewController:

final class ArticlesViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
    private let tableView = UITableView()
    private var articles: [[String: Any]] = []
    private var isLoading = false
    private var errorMessage: String?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        title = "Articles"
        tableView.dataSource = self
        tableView.delegate = self
        tableView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        load()
    }

    private func load() {
        isLoading = true
        let url = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")!
        URLSession.shared.dataTask(with: url) { data, _, error in
            DispatchQueue.main.async {
                self.isLoading = false
                if let error = error {
                    self.errorMessage = error.localizedDescription
                    self.tableView.reloadData()
                    return
                }
                guard let data = data,
                      let ids = try? JSONSerialization.jsonObject(with: data) as? [Int] else {
                    self.errorMessage = "Decode failed"
                    self.tableView.reloadData()
                    return
                }
                self.fetchDetails(for: Array(ids.prefix(20)))
            }
        }.resume()
    }

    private func fetchDetails(for ids: [Int]) {
        let group = DispatchGroup()
        var fetched: [[String: Any]] = []
        for id in ids {
            group.enter()
            let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")!
            URLSession.shared.dataTask(with: url) { data, _, _ in
                defer { group.leave() }
                guard let data = data,
                      let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
                fetched.append(json)
            }.resume()
        }
        group.notify(queue: .main) {
            self.articles = fetched
            self.tableView.reloadData()
        }
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        articles.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = articles[indexPath.row]["title"] as? String ?? "(no title)"
        return cell
    }
}

(Yes, this is a horror show on purpose — most legacy code looks like this.)

Build

Run it, confirm the table populates with 20 Hacker News stories.

Tasks

Task 1 — Model (30 min)

Replace [String: Any] with a real struct Article: Identifiable, Codable, Equatable:

struct Article: Identifiable, Codable, Equatable {
    let id: Int
    let title: String
    let url: String?
    let by: String
    let score: Int
}

Update parsing to use JSONDecoder not JSONSerialization.

Task 2 — Service (30 min)

Extract networking into:

protocol HackerNewsService {
    func topStoryIDs() async throws -> [Int]
    func article(id: Int) async throws -> Article
}

final class LiveHackerNewsService: HackerNewsService { /* … */ }

Use async/await. Use withThrowingTaskGroup to fetch article details in parallel.

Task 3 — ViewModel (45 min)

@Observable @MainActor
final class ArticlesViewModel {
    enum State { case idle, loading, loaded([Article]), error(String) }
    private(set) var state: State = .idle
    private let service: HackerNewsService
    init(service: HackerNewsService) { self.service = service }
    func load() async { /* set loading, fetch IDs, fetch details, set loaded or error */ }
}

Task 4 — View (30 min)

Replace UITableView with a SwiftUI List:

struct ArticlesView: View {
    @State private var viewModel: ArticlesViewModel
    init(service: HackerNewsService = LiveHackerNewsService()) {
        _viewModel = State(initialValue: ArticlesViewModel(service: service))
    }
    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Articles")
                .task { await viewModel.load() }
                .refreshable { await viewModel.load() }
        }
    }
    @ViewBuilder var content: some View {
        switch viewModel.state {
        case .idle, .loading: ProgressView()
        case .loaded(let articles): List(articles) { article in /* row */ }
        case .error(let msg): VStack { Text(msg); Button("Retry") { Task { await viewModel.load() } } }
        }
    }
}

Task 5 — Tests (45 min)

Create ArticlesViewModelTests:

struct MockService: HackerNewsService {
    let result: Result<[Article], any Error>
    func topStoryIDs() async throws -> [Int] { [1] }
    func article(id: Int) async throws -> Article {
        switch result { case .success(let a): a.first!; case .failure(let e): throw e }
    }
}

Write tests for: load() → .loaded; failure → .error; pre-load state is .idle.

Stretch

  • Add pull-to-refresh state distinct from initial load.
  • Add per-article detail screen via NavigationLink.
  • Persist last-fetched articles via SwiftData; show cached on launch before network completes.
  • Add a Combine-based variant for comparison; note where async/await is cleaner.

Notes

The point isn’t to memorize MVVM syntax — it’s to feel the difference between the starter mess and the refactored version. After this lab, you should be able to spot Massive View Controller smell in a code review within seconds.


Next: Lab 12.2 — Modularize a Monolith