iOS Refactoring Interview Simulator

Refactor a Massive ViewController into clean MVVM with unit tests.

Introduction

30 Second Summary

Technical interviews at top companies often hand you a working but messy codebase and ask you to improve it under time pressure. Your ability to prioritize, refactor systematically, and articulate tradeoffs separates senior engineers from everyone else.

In this project, you will take a deliberately tangled 200-line iOS ViewController and systematically refactor it into clean, testable MVVM architecture. You will extract a protocol-based networking layer, implement explicit state management, add dependency injection, and prove it all works with passing unit tests.

What You'll Build

By the end, you will run your refactored app in the Simulator, watch reservations load with pagination and error handling, then hit Cmd+U and see three unit tests pass in complete isolation from UIKit.

By the end of this project, you'll have:

  • A protocol-based networking layer you can swap with a mock in one line, eliminating unsafe dictionary access and enabling full testability.
  • A ViewModel with explicit state management (idle, loading, loaded, error) that is fully unit-testable without touching any UI code.
  • A slim ViewController (~80 lines) and a DI container that wires everything together, proving you can deliver production-ready architecture under time pressure.
  • Secret Mission: Add a Repository pattern with local caching that gracefully serves cached reservations when the network fails.

Are there any prerequisites?

You need experience with Swift and UIKit, plus Xcode installed on your Mac. Familiarity with closures, protocols, and async/await will help you move quickly through the refactoring steps.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your upcoming refactoring interview.

Set Up the Messy Starting Project

You have a clear goal: practice refactoring a Massive ViewController under time pressure. Before you can refactor anything, you need the messy starting code to work with.

In this step, you will create an Xcode project and paste in a deliberately tangled 170-line ViewController that mixes networking, UI, pagination, and state management into a single class. This is the kind of code you will face in your refactoring interview.

In this step, get ready to:
  • Create a new Xcode project called ReservationManager.
  • Add the messy ReservationsViewController with all logic in one file.
  • Run the app in Simulator and confirm it displays reservation data.
Create the Xcode project

You need a fresh iOS project configured for programmatic UI (no storyboard). This mirrors how most production iOS apps are set up at companies like Expedia.

  • Open Xcode.
  • Click Create New Project.
  • Select the iOS tab at the top, then choose App and click Next.
  • Set Product Name to ReservationManager.
  • Set Interface to Storyboard and Language to Swift.
  • Click Next, choose your Desktop as the save location, and click Create.

Xcode opens your new project with a default storyboard-based setup. You will switch to a code-only approach now.

  • In the Project Navigator (left sidebar), right-click Main.storyboard and select Delete.
  • Choose Move to Trash when prompted.
  • Click on the ReservationManager project (blue icon) at the top of the Project Navigator.
  • Select the ReservationManager target, then click the Info tab.
  • Under Custom iOS Target Properties, find the row Main storyboard file base name and delete it by clicking the minus button.
  • Expand Application Scene Manifest > Scene Configuration > Application Session Role > Item 0.
  • Delete the Storyboard Name row by clicking its minus button.

Why remove the storyboard?

Production iOS apps at scale use programmatic UI because storyboards cause merge conflicts in teams and hide view controller dependencies. Your refactoring interview code will be purely programmatic.

Add the messy ReservationsViewController

Now you will replace the default ViewController.swift with a deliberately messy ViewController that tangles every responsibility into one class. This is your interview starting point.

  • In the Project Navigator, right-click ViewController.swift and select Delete, then Move to Trash.
  • Right-click the ReservationManager folder in the Project Navigator and select New File.
  • Choose Swift File, click Next, name it ReservationsViewController, and click Create.
  • Delete the default contents of the new file and paste the following code starting from the top:
import UIKit

class ReservationsViewController: UIViewController,
    UITableViewDataSource, UITableViewDelegate {

    private let tableView = UITableView()
    private let activityIndicator = UIActivityIndicatorView(style: .large)
    private let errorLabel = UILabel()
    private let retryButton = UIButton(type: .system)

    // State scattered as loose booleans - no single source of truth
    private var reservations: [[String: Any]] = []
    private var currentPage = 1
    private var isLoading = false
    private var hasMore = true
    private var hasError = false

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Reservations"
        view.backgroundColor = .systemBackground
        setupTableView()
        setupLoadingIndicator()
        setupErrorViews()
        loadReservations()
    }

What does this code do?

This declares a single class that conforms to both UITableViewDataSource and UITableViewDelegate directly. Notice the state is stored as raw dictionaries ([[String: Any]]) and loose booleans instead of typed models or a state enum.

  • Continue pasting the UI setup methods directly below the previous code:
    private func setupTableView() {
        tableView.frame = view.bounds
        tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)
    }

    private func setupLoadingIndicator() {
        activityIndicator.center = view.center
        activityIndicator.hidesWhenStopped = true
        view.addSubview(activityIndicator)
    }

    private func setupErrorViews() {
        errorLabel.textAlignment = .center
        errorLabel.numberOfLines = 0
        errorLabel.isHidden = true
        errorLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(errorLabel)

        retryButton.setTitle("Retry", for: .normal)
        retryButton.isHidden = true
        retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
        retryButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(retryButton)
    }

What does this code do?

Three setup methods configure the table view, a loading spinner, and error views. UI setup is mixed into the same class as networking and business logic. In a clean architecture these would live in the ViewController alone, but here they share space with everything else.

  • Continue pasting the Auto Layout constraints and networking method:
    private func setupConstraints() {
        NSLayoutConstraint.activate([
            errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -20),
            errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            retryButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            retryButton.topAnchor.constraint(equalTo: errorLabel.bottomAnchor, constant: 12)
        ])
    }

    // Networking logic inline - no protocol, no separation, untestable
    private func loadReservations() {
        guard !isLoading else { return }
        isLoading = true
        hasError = false

        if reservations.isEmpty {
            activityIndicator.startAnimating()
            tableView.isHidden = true
            errorLabel.isHidden = true
            retryButton.isHidden = true
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
            guard let self = self else { return }
            self.handleNetworkResponse()
        }
    }

What does this code do?

The constraints position error UI in the center of the screen. The loadReservations() method handles networking inline using DispatchQueue to simulate an API call. There is no protocol boundary, so you cannot inject a mock for testing.

  • Continue pasting the network response handler and data generation:
    private func handleNetworkResponse() {
        let shouldFail = currentPage > 1 && Bool.random()
        if shouldFail {
            isLoading = false
            if reservations.isEmpty {
                hasError = true
                activityIndicator.stopAnimating()
                errorLabel.text = "Failed to load reservations. Please try again."
                errorLabel.isHidden = false
                retryButton.isHidden = false
                tableView.isHidden = true
            }
            return
        }

        let newReservations = generateReservations(page: currentPage)
        reservations.append(contentsOf: newReservations)
        currentPage += 1
        hasMore = currentPage <= 5

        activityIndicator.stopAnimating()
        tableView.isHidden = false
        errorLabel.isHidden = true
        retryButton.isHidden = true
        tableView.reloadData()
        isLoading = false
    }

    @objc private func retryTapped() {
        loadReservations()
    }

What does this code do?

The response handler directly manipulates UI state (hiding views, stopping spinners) alongside business logic (tracking pages, accumulating data). This tangling is the core code smell. Business logic and UI are inseparable here.

  • Continue pasting the data generation method:
    private func generateReservations(page: Int) -> [[String: Any]] {
        let guests = ["Alice Johnson", "Bob Smith", "Carol White",
                      "David Brown", "Eva Martinez", "Frank Lee",
                      "Grace Kim", "Henry Wilson", "Iris Chen", "Jack Davis"]
        let properties = ["Grand Hotel Downtown", "Seaside Resort",
                          "Mountain Lodge", "City Center Inn", "Lakefront Suites"]
        let statuses = ["confirmed", "checked_in", "checked_out",
                        "cancelled", "pending"]

        return (0..<10).map { i in
            let index = (page - 1) * 10 + i
            let checkIn = Date().addingTimeInterval(Double(index) * 86400)
            let checkOut = checkIn.addingTimeInterval(Double((i % 3) + 1) * 86400)

            return [
                "id": "\(index + 1)",
                "guestName": guests[i % guests.count],
                "propertyName": properties[i % properties.count],
                "checkIn": checkIn,
                "checkOut": checkOut,
                "status": statuses[i % statuses.count],
                "totalAmount": Double((i + 1) * 150 + page * 50)
            ] as [String: Any]
        }
    }

What does this code do?

This generates fake reservation data as raw dictionaries. Using [String: Any] means every access requires force-casting with as? and provides zero compile-time safety. A typo in a key name causes a silent runtime bug.

  • Continue pasting the table view data source and delegate methods:
    // MARK: - TableView (data source + delegate mixed into the same massive class)
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return reservations.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let reservation = reservations[indexPath.row]

        // Unsafe dictionary access on every cell
        let guestName = reservation["guestName"] as? String ?? "Unknown"
        let propertyName = reservation["propertyName"] as? String ?? "Unknown"
        let status = reservation["status"] as? String ?? "pending"
        let amount = reservation["totalAmount"] as? Double ?? 0.0

        // DateFormatter created every cell render - expensive!
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .none
        let checkIn = reservation["checkIn"] as? Date ?? Date()
        let checkOut = reservation["checkOut"] as? Date ?? Date()
        let checkInStr = formatter.string(from: checkIn)
        let checkOutStr = formatter.string(from: checkOut)
        let amountStr = String(format: "$%.2f", amount)

        var content = cell.defaultContentConfiguration()
        content.text = "\(guestName) - \(propertyName)"
        content.secondaryText = "\(checkInStr) \u{2192} \(checkOutStr) | \(statusDisplayName(status)) | \(amountStr)"
        content.secondaryTextProperties.color = statusColor(status)
        cell.contentConfiguration = content
        return cell
    }

What does this code do?

The cell configuration uses unsafe dictionary access (as? String ?? "Unknown") on every render. A new DateFormatter is created for every cell, which is a well-known performance anti-pattern in iOS.

  • Finish the file by pasting the pagination trigger and helper methods, then the closing brace:
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell,
                   forRowAt indexPath: IndexPath) {
        if hasMore && !isLoading && indexPath.row >= reservations.count - 3 {
            loadReservations()
        }
    }

    // Helpers that should live on a model type
    private func statusDisplayName(_ status: String) -> String {
        switch status {
        case "confirmed": return "Confirmed"
        case "checked_in": return "Checked In"
        case "checked_out": return "Checked Out"
        case "cancelled": return "Cancelled"
        case "pending": return "Pending"
        default: return status
        }
    }

    private func statusColor(_ status: String) -> UIColor {
        switch status {
        case "confirmed": return .systemGreen
        case "checked_in": return .systemBlue
        case "checked_out": return .secondaryLabel
        case "cancelled": return .systemRed
        case "pending": return .systemOrange
        default: return .secondaryLabel
        }
    }
}

What does this code do?

The willDisplay method triggers pagination, mixing scroll logic into the ViewController. The status helpers convert raw strings to display values. In clean architecture these would be computed properties on a typed enum.

  • Save ReservationsViewController.swift with Cmd+S.

Why is this called a Massive ViewController?

This single class handles networking, UI layout, data generation, pagination, date formatting, state management, and display logic. You cannot test any of it without launching the full UI.

The Massive View Controller anti-pattern is the most common code smell in UIKit apps and the primary target of iOS refactoring interviews.

Update SceneDelegate to use the new ViewController

The app still points at the deleted storyboard. You need to wire ReservationsViewController as the root programmatically.

  • Open SceneDelegate.swift in the Project Navigator.
  • Replace the entire contents of the file with the following:
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        // Directly create the ViewController - no DI, no container
        let viewController = ReservationsViewController()
        window?.rootViewController = UINavigationController(rootViewController: viewController)
        window?.makeKeyAndVisible()
    }
}

What does this code do?

This manually creates the window and sets ReservationsViewController as the root, wrapped in a UINavigationController for the navigation bar. No dependency injection yet. The ViewController creates everything it needs internally.

  • Save SceneDelegate.swift with Cmd+S.
  • You also need to update viewDidLoad in your ViewController to call setupConstraints(). Open ReservationsViewController.swift and find the viewDidLoad method.
  • Add setupConstraints() after the setupErrorViews() call, so viewDidLoad looks like this:
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Reservations"
        view.backgroundColor = .systemBackground
        setupTableView()
        setupLoadingIndicator()
        setupErrorViews()
        setupConstraints()
        loadReservations()
    }
  • Save the file again with Cmd+S.
Run the app and verify it works

Before you run, what do you expect to see? Think about what the code does: it generates 10 fake reservations per page and displays them in a table.

  • Select an iPhone simulator from the device menu in the Xcode toolbar (e.g. iPhone 16).
  • Press Cmd+R to build and run.

You should see a Reservations screen with a navigation bar title, followed by a list of guest bookings. Each row shows a guest name, property name, date range, status, and dollar amount.

  • Scroll down toward the bottom of the list.
  • More items load automatically as you approach the last few rows (pagination in action).

App crashes or shows a blank screen?

  • If you see a crash mentioning Main.storyboard, confirm you deleted both the storyboard file AND the two Info.plist entries (Main storyboard file base name and the Storyboard Name row under Application Scene Manifest).
  • If you see a build error about missing methods, confirm all seven code chunks are pasted in order inside the class braces, and that setupConstraints() is called in viewDidLoad.
  • If cells appear blank, check that the closing brace } at the end closes the class properly.

Need help? Why is my ReservationManager app crashing on launch?

✔️ Awesome, I've got everything!

Great. Confirm you have saved both ReservationsViewController.swift and SceneDelegate.swift.

ⓧ I'd like to double check the full code

Scroll up and confirm each of the following sections is present in your ReservationsViewController.swift, in this order:

  • Import, class declaration, properties, and viewDidLoad (calling setupTableView, setupLoadingIndicator, setupErrorViews, setupConstraints, loadReservations).
  • Three setup methods: setupTableView(), setupLoadingIndicator(), setupErrorViews().
  • Constraints in setupConstraints() and the loadReservations() method with DispatchQueue delay.
  • The handleNetworkResponse() method and retryTapped().
  • The generateReservations(page:) method returning [[String: Any]].
  • Table view data source: numberOfRowsInSection and cellForRowAt with inline DateFormatter.
  • Pagination in willDisplay, statusDisplayName(_:), statusColor(_:), and the final closing }.

For SceneDelegate.swift, confirm it creates a ReservationsViewController() and wraps it in a UINavigationController.

The app works perfectly from a user perspective. But the developer experience is terrible. Next, you will read through this code with fresh eyes and identify exactly what to fix first.

Identify Code Smells and Plan Your Attack

Your app runs. It loads reservations, paginates on scroll, and shows status colors. From a user's perspective, everything works.

But in a refactoring interview, Jens is not watching whether the app runs. He is watching how you analyze the codebase before you touch it. Jumping straight into coding signals junior-level thinking. Senior engineers identify the highest-impact refactoring targets first, then execute with a plan.

In this step, get ready to:
  • Confirm all app functionality works (initial load, pagination, error state with retry).
  • Identify the 6 code smells hiding inside the Massive ViewController.
  • Create a prioritized refactoring plan based on risk vs. value.
Confirm the app works before changing anything

A critical refactoring rule: never change code you have not proven works first. If something breaks later, you need a baseline to compare against.

  • Switch back to the Simulator where your app is running.
  • Confirm the initial load works: you should see a list of guest reservations with names, property names, dates, status colors, and dollar amounts.
  • Scroll down near the bottom of the list. New reservations should load automatically (pagination).

Now test the error state. The simulated networking in your ViewController has a condition that randomly fails on pages after the first. You will temporarily force it to always fail so you can see the error UI.

  • Return to ReservationsViewController.swift in Xcode.
  • Find the line inside the simulated fetch that controls whether the request fails (a boolean condition that uses Bool.random() or a page check).
  • Temporarily change that condition so it always evaluates to true (force every fetch to fail).
  • Run the app again with Cmd+R.
  • You should see an error message and a Retry button on screen.
  • Tap Retry to confirm it attempts to reload (it will fail again since you forced the error).

Why test before refactoring?

If you refactor first and something breaks, you will not know whether the bug is old or new. Confirming all paths work (happy path, pagination, error + retry) gives you a behavioral contract to preserve throughout the refactoring.

  • Revert the failure condition back to its original logic so the app works normally again.
  • Run the app once more with Cmd+R to confirm it loads successfully.

App crashing after reverting?

Make sure you reverted the condition exactly. If you accidentally deleted surrounding code, use Cmd+Z to undo until the file matches what you had before the temporary edit.

help me fix my reverted code so the app builds again

Read the code and identify the smells

Now that you have confirmed the app's behavior, read through ReservationsViewController.swift from top to bottom. You are looking for patterns that make the code fragile, untestable, or hard to extend.

  • Scroll to the top of ReservationsViewController.swift and read through the entire file.
  • As you read, look for these six specific code smells:
  • No typed model. Data comes back as raw dictionaries. Every property access is an unsafe force-cast or optional chain that can crash at runtime.
  • Networking logic inline. The simulated API fetch lives directly inside the ViewController. You cannot swap it for a mock or a real API without rewriting the VC.
  • UI setup mixed with business logic. Table view configuration, Auto Layout constraints, and data-loading decisions all live in the same class.
  • Scattered state booleans. Loading state is tracked by individual Bool variables scattered across the file instead of a single, explicit state enum.
  • DateFormatter created on every cell. A new DateFormatter is instantiated each time a cell renders. This is expensive and wasteful.
  • No testability whatsoever. To test any logic, you would need to instantiate a full UIViewController with a view hierarchy. There is no way to unit test business rules in isolation.

What is the Massive View Controller anti-pattern?

In UIKit apps, it is tempting to put everything inside the ViewController because that is what Xcode templates give you. Over time, the VC accumulates networking, data transformation, state management, and UI code. It becomes impossible to test, difficult to extend, and painful to debug.

The industry joke is that MVC stands for "Massive View Controller" instead of Model-View-Controller. Your file is a textbook example of this anti-pattern.

Prioritize your refactoring plan

You have 60 minutes in the real interview. You cannot fix everything at once. The senior move is to rank each refactoring by risk vs. value: which change delivers the most safety and testability with the least chance of breaking the app?

  • Write down (mentally or on paper) your refactoring priority order:
  • Priority 1: Extract a typed Reservation model. Eliminates unsafe dictionary access that can crash at runtime. Low risk because it does not change behavior, only adds type safety.
  • Priority 2: Extract networking behind a protocol. Creates a ReservationServiceProtocol boundary so you can swap implementations. Enables mocking for tests.
  • Priority 3: Move business logic to a ViewModel with explicit states. Replaces scattered booleans with a ViewState enum (idle, loading, loaded, error). The ViewController shrinks to pure UI rendering.
  • Priority 4: Add Dependency Injection so everything is testable. The ViewModel accepts its service through the initializer instead of creating it internally. Now you can inject mocks and write unit tests.

Why this order?

Each step builds on the previous one. You cannot inject a mock service (Priority 4) until the service exists behind a protocol (Priority 2). You cannot build the protocol until you have a typed model for it to return (Priority 1). This ordering minimizes the risk of breaking the app between refactoring steps.

In the interview, articulating this dependency chain out loud demonstrates the architectural judgment that separates senior from mid-level engineers.

Practice saying it out loud

In the real interview, you will narrate your thinking while coding. Practice saying: "The highest-value refactor is extracting a typed model and protocol-based service, because it eliminates runtime crashes from dictionary access and enables testing. The ViewModel extraction follows because it decouples business logic from UIKit."

Before you move on, confirm you can articulate the plan. What is the single most impactful thing to refactor first, and why?

The answer: extracting the typed model is highest-value and lowest-risk. It eliminates an entire class of runtime crashes (force-unwrapped dictionary values) without changing any observable behavior. Everything else builds on it.

You have your battle plan. Next up, you will execute Priority 1 and 2: extracting the typed model and the protocol-based networking layer into their own files.

Extract the Model and Networking Layer

You have a prioritized refactoring plan. Now it is time to execute the highest-value move: eliminating raw dictionary access.

Every time the current code reads a value from a dictionary, it risks a runtime crash if the key is misspelled or the type is wrong. Extracting a typed model and a protocol-based service removes that entire class of bugs in one shot, while giving you a clean boundary you can test against later.

In this step, get ready to:
  • Create a typed Reservation model with a Status enum.
  • Create a protocol-based networking service.
  • Update the ViewController to use the new model and service.
Create the Reservation model

The messy ViewController currently uses raw dictionaries like dict["guest_name"] as? String to access data. A single typo crashes the app at runtime with no compiler warning. A proper Swift struct catches these errors at compile time.

  • In Xcode, right-click the ReservationManager group in the Project Navigator.
  • Select New File, choose Swift File, and name it Reservation.swift.
  • Replace the file contents with the struct definition and Status enum cases:
import UIKit

struct Reservation: Equatable {
    let id: String
    let guestName: String
    let propertyName: String
    let checkIn: Date
    let checkOut: Date
    let status: Status
    let totalAmount: Double

    enum Status: String {
        case confirmed
        case checkedIn = "checked_in"
        case checkedOut = "checked_out"
        case cancelled
        case pending
    }
}

What does this code do?

The Reservation struct gives every field an explicit type. The compiler now catches any misspelling or type mismatch before the app runs.

The Status enum uses raw strings matching the API format (like checked_in) so parsing is handled automatically by Swift.

  • Add the computed properties inside the Status enum, just before the closing } of the enum:
        var displayName: String {
            switch self {
            case .confirmed: return "Confirmed"
            case .checkedIn: return "Checked In"
            case .checkedOut: return "Checked Out"
            case .cancelled: return "Cancelled"
            case .pending: return "Pending"
            }
        }

        var displayColor: UIColor {
            switch self {
            case .confirmed: return .systemGreen
            case .checkedIn: return .systemBlue
            case .checkedOut: return .secondaryLabel
            case .cancelled: return .systemRed
            case .pending: return .systemOrange
            }
        }

What do these computed properties do?

Each status case knows how to display itself. displayName gives a human-readable label. displayColor returns the UIColor for that status.

This eliminates the scattered switch statements that were inside the ViewController's cell configuration.

  • Save Reservation.swift.
  • Press Cmd+B to build the project.
  • You should see Build Succeeded in the status bar. The model compiles cleanly.

Seeing a build error?

Check that the displayColor and displayName properties are inside the Status enum's braces, not outside them. The indentation must nest them inside enum Status.

Also confirm that import UIKit is at the top of the file. The UIColor type requires it.

Help me fix my Reservation.swift build error

Create the networking service with a protocol

The networking code currently lives inside the ViewController, making it impossible to swap in a mock for testing. Extracting it behind a protocol creates a seam you can test against.

  • Right-click the ReservationManager group again and create a new Swift file named ReservationService.swift.
  • Replace the file contents with the protocol definition and error enum:
import Foundation

// Protocol defines the contract any service must fulfill
protocol ReservationServiceProtocol {
    func fetchReservations(page: Int) async throws -> [Reservation]
}

// Typed errors with user-facing messages
enum ReservationServiceError: Error, LocalizedError {
    case networkFailure
    case invalidResponse

    var errorDescription: String? {
        switch self {
        case .networkFailure: return "Failed to load reservations. Please try again."
        case .invalidResponse: return "Received an invalid response from the server."
        }
    }
}

Why a protocol instead of just a class?

The ReservationServiceProtocol defines a contract: any type that conforms to it must provide fetchReservations(page:). In production, the real service hits the network. In tests, a mock returns canned data instantly.

This is the protocol-oriented programming pattern that iOS interviewers specifically look for.

  • Add the concrete service class below the error enum in the same file:
final class ReservationService: ReservationServiceProtocol {
    // Simulates a network request with artificial delay
    func fetchReservations(page: Int) async throws -> [Reservation] {
        try await Task.sleep(nanoseconds: 800_000_000)
        let shouldFail = page > 1 && Bool.random()
        if shouldFail { throw ReservationServiceError.networkFailure }
        return generateReservations(page: page)
    }

    // Generates mock reservation data for a given page
    private func generateReservations(page: Int) -> [Reservation] {
        let guests = ["Alice Johnson", "Bob Smith", "Carol White", "David Brown", "Eva Martinez",
                      "Frank Lee", "Grace Kim", "Henry Wilson", "Iris Chen", "Jack Davis"]
        let properties = ["Grand Hotel Downtown", "Seaside Resort", "Mountain Lodge",
                          "City Center Inn", "Lakefront Suites"]
        let statuses: [Reservation.Status] = [.confirmed, .checkedIn, .checkedOut, .cancelled, .pending]
        return (0..<10).map { i in
            let index = (page - 1) * 10 + i
            let checkIn = Date().addingTimeInterval(Double(index) * 86400)
            let checkOut = checkIn.addingTimeInterval(Double((i % 3) + 1) * 86400)
            return Reservation(
                id: "\(index + 1)",
                guestName: guests[i % guests.count],
                propertyName: properties[i % properties.count],
                checkIn: checkIn,
                checkOut: checkOut,
                status: statuses[i % statuses.count],
                totalAmount: Double((i + 1) * 150 + page * 50)
            )
        }
    }
}

What does this code do?

The ReservationService class conforms to the protocol. It simulates network latency with Task.sleep and randomly fails on pages beyond the first to mimic real-world flakiness.

The generateReservations(page:) helper creates typed Reservation values. This is the exact logic that was previously scattered as inline dictionary construction inside the ViewController.

  • Save ReservationService.swift.
  • Press Cmd+B to build.
  • You should see Build Succeeded again. Both new files compile cleanly.

Build fails on the service file?

Ensure Reservation.swift is added to the same target as ReservationService.swift. If Xcode cannot find the Reservation type, select the file in the Project Navigator, open the File Inspector (right panel), and check the Target Membership box for ReservationManager.

Help me fix my ReservationService build error

✔️ Awesome, I've got everything!

Great. Make sure both Reservation.swift and ReservationService.swift are saved before moving on.

ⓧ I'd like to double check the full code

Since these files are longer than 30 lines combined, confirm you have each section in order:

  • Reservation.swift: The Reservation struct with all 7 fields, then the nested Status enum with 5 cases, then displayName and displayColor computed properties.
  • ReservationService.swift: The ReservationServiceProtocol, then ReservationServiceError enum with errorDescription, then the ReservationService class with fetchReservations(page:) and generateReservations(page:).
Update the ViewController to use the new model and service

The two new files are ready. Now you will gut the messy ViewController and replace its inline networking and dictionary access with clean calls to your service, using the typed model.

  • Open ReservationsViewController.swift in the editor.
  • Select all the content (Cmd+A) and delete it.
  • Replace it with the class declaration, properties, and viewDidLoad:
import UIKit

final class ReservationsViewController: UIViewController {
    // Networking through the protocol boundary
    private let service: ReservationServiceProtocol = ReservationService()
    private var reservations: [Reservation] = []
    private var currentPage = 1
    private var hasMore = true
    private var isLoading = false

    // UI components
    private let tableView = UITableView()
    private let activityIndicator = UIActivityIndicatorView(style: .large)
    private let errorLabel = UILabel()
    private let retryButton = UIButton(type: .system)

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Reservations"
        view.backgroundColor = .systemBackground
        setupUI()
        Task { await loadReservations() }
    }

What changed here?

The service is now typed as ReservationServiceProtocol instead of inline networking code. The reservations array holds typed Reservation values instead of dictionaries.

Business logic (pagination state, loading flag) still lives here. That is intentional for now. You will extract it to a ViewModel in the next step.

  • Add the setupUI() method directly below viewDidLoad:
    private func setupUI() {
        tableView.frame = view.bounds
        tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)

        activityIndicator.center = view.center
        activityIndicator.hidesWhenStopped = true
        view.addSubview(activityIndicator)

        errorLabel.textAlignment = .center
        errorLabel.numberOfLines = 0
        errorLabel.isHidden = true
        errorLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(errorLabel)

        retryButton.setTitle("Retry", for: .normal)
        retryButton.isHidden = true
        retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
        retryButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(retryButton)

        NSLayoutConstraint.activate([
            errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -20),
            errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            retryButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            retryButton.topAnchor.constraint(equalTo: errorLabel.bottomAnchor, constant: 12)
        ])
    }
  • Save the file and press Cmd+B to build.
  • You will see build errors because loadReservations() and retryTapped() do not exist yet. That confirms Xcode is reading your new code.
  • Add the loadReservations() method below setupUI():
    private func loadReservations() async {
        guard !isLoading else { return }
        isLoading = true

        if reservations.isEmpty {
            activityIndicator.startAnimating()
            tableView.isHidden = true
        }

        do {
            // Call the service through the protocol
            let newReservations = try await service.fetchReservations(page: currentPage)
            reservations.append(contentsOf: newReservations)
            currentPage += 1
            hasMore = currentPage <= 5
            activityIndicator.stopAnimating()
            tableView.isHidden = false
            errorLabel.isHidden = true
            retryButton.isHidden = true
            tableView.reloadData()
        } catch {
            activityIndicator.stopAnimating()
            if reservations.isEmpty {
                tableView.isHidden = true
                errorLabel.text = error.localizedDescription
                errorLabel.isHidden = false
                retryButton.isHidden = false
            }
        }

        isLoading = false
    }

What does this code do?

This method does the same work as before but calls service.fetchReservations(page:) through the protocol instead of running inline networking. The result is a typed [Reservation] array, not a dictionary.

UI updates (showing/hiding the spinner, error label) still live here. You will move them out when you add the ViewModel.

  • Add the retryTapped() handler, the closing brace for the class, and the table view extension below loadReservations():
    @objc private func retryTapped() {
        Task { await loadReservations() }
    }
}

extension ReservationsViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return reservations.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let reservation = reservations[indexPath.row]
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .none
        let checkInStr = formatter.string(from: reservation.checkIn)
        let checkOutStr = formatter.string(from: reservation.checkOut)
        let amountStr = String(format: "$%.2f", reservation.totalAmount)

        var content = cell.defaultContentConfiguration()
        content.text = "\(reservation.guestName) - \(reservation.propertyName)"
        content.secondaryText = "\(checkInStr) \u{2192} \(checkOutStr) | \(reservation.status.displayName) | \(amountStr)"
        content.secondaryTextProperties.color = reservation.status.displayColor
        cell.contentConfiguration = content
        return cell
    }

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard hasMore, !isLoading, indexPath.row >= reservations.count - 3 else { return }
        Task { await loadReservations() }
    }
}

What does this code do?

The cell configuration now reads typed properties like reservation.guestName and reservation.status.displayColor instead of unsafe dictionary lookups.

The willDisplay method triggers pagination when the user scrolls near the bottom. Same behavior as before, but now with typed data.

Before you run the app, what do you expect to see? The UI should look exactly the same as before. If the refactoring is correct, the behavior is preserved with zero visual change.

  • Save ReservationsViewController.swift.
  • Press Cmd+R to build and run the app in Simulator.

You should see the same Reservations screen as before. Guest names, property names, dates, colored status labels, and dollar amounts all display correctly. Scrolling near the bottom still loads more items.

App crashes or shows a blank screen?

If you see a crash, check the console for the error. A common issue is a missing closing brace. Make sure the class closing } appears after retryTapped() and before the extension keyword.

If the screen loads but shows no data, verify that ReservationService is assigned to the service property. The line should read private let service: ReservationServiceProtocol = ReservationService().

Help me debug my ViewController after the refactoring

The app looks identical but the code underneath is fundamentally different. You now have typed data, a protocol boundary, and zero dictionary access. Next, you will pull the business logic out of the ViewController and into a dedicated ViewModel with explicit state management.

Create the ViewModel with State Management

Your networking layer is clean and your model is typed, but the ViewController still owns all the business logic. Pagination tracking, loading state, data accumulation, and date formatting all live inside a UIViewController.

You cannot unit test any of this without instantiating the full ViewController. In this step, you will extract all logic into a ViewModel with explicit state management, leaving the ViewController as a thin UI shell.

In this step, get ready to:
  • Create a ReservationsViewModel with a ViewState enum and all business logic.
  • Add closure-based binding so the ViewController reacts to state changes.
  • Slim the ViewController down to ~80 lines of pure UI code.
Build the ViewModel with state management

The ViewModel will hold all the logic your ViewController currently owns. Its key innovation is a ViewState enum that explicitly tracks whether the screen is idle, loading, showing data, or displaying an error.

  • Create a new Swift file called ReservationsViewModel.swift in your ReservationManager group.
  • Add the ViewState enum and the class declaration with its properties by pasting this code:
import Foundation

// Explicit states replace scattered boolean flags
enum ViewState: Equatable {
    case idle
    case loading
    case loaded
    case error(String)
}

@MainActor
final class ReservationsViewModel {
    private let service: ReservationServiceProtocol
    private var currentPage = 1
    private var hasMore = true
    private var isLoading = false

    private(set) var reservations: [Reservation] = []
    private(set) var state: ViewState = .idle {
        didSet { onStateChange?(state) }
    }

    // Closure the ViewController will set to react to state changes
    var onStateChange: ((ViewState) -> Void)?

    init(service: ReservationServiceProtocol) {
        self.service = service
    }

What does this code do?

The ViewState enum gives you four explicit states instead of scattered booleans like isLoading and hasError. This makes impossible states unrepresentable.

The didSet on state fires onStateChange every time the state updates. This is the binding mechanism the ViewController will observe.

  • Add the loading methods directly below the init:
    func loadReservations() async {
        guard !isLoading else { return }
        isLoading = true
        if reservations.isEmpty { state = .loading }
        do {
            let newReservations = try await service.fetchReservations(page: currentPage)
            reservations.append(contentsOf: newReservations)
            currentPage += 1
            hasMore = currentPage <= 5
            state = .loaded
        } catch {
            // Only show error if we have no data to display
            if reservations.isEmpty { state = .error(error.localizedDescription) }
        }
        isLoading = false
    }

    func loadNextPageIfNeeded(currentIndex: Int) async {
        guard hasMore, !isLoading, currentIndex >= reservations.count - 3 else { return }
        await loadReservations()
    }

What does this code do?

The loadReservations() method is the same pagination logic that lived in the ViewController. The guard at the top prevents duplicate requests.

The loadNextPageIfNeeded(currentIndex:) method triggers pagination when the user scrolls within 3 items of the bottom. The ViewController will call this from its table delegate.

  • Add the presentation helper and close the class by pasting this below the loading methods:
    // Formats a reservation for display without coupling to UIKit
    var formattedReservation: (Reservation) -> (title: String, subtitle: String, statusColor: Any) {
        return { reservation in
            let formatter = DateFormatter()
            formatter.dateStyle = .medium
            formatter.timeStyle = .none
            let checkInStr = formatter.string(from: reservation.checkIn)
            let checkOutStr = formatter.string(from: reservation.checkOut)
            let amountStr = String(format: "$%.2f", reservation.totalAmount)
            let title = "\(reservation.guestName) - \(reservation.propertyName)"
            let subtitle = "\(checkInStr) \u{2192} \(checkOutStr) | \(reservation.status.displayName) | \(amountStr)"
            return (title, subtitle, reservation.status.displayColor)
        }
    }
}

Why keep formatting in the ViewModel?

The formattedReservation closure transforms raw model data into display-ready strings. This keeps the ViewController completely free of formatting logic. The VC just asks for a formatted result and renders it.

  • Save ReservationsViewModel.swift.
  • Press Cmd+B to build. The project compiles with no errors because the ViewModel is a standalone file that does not yet interfere with the existing ViewController.

Build fails with errors?

  • Check that the file is added to the ReservationManager target. In the File Inspector (right panel), the target checkbox must be checked.
  • Confirm the closing brace } matches the class opening. Each code chunk nests inside the class.

Why won't my ViewModel file compile?

✔️ Awesome, I've got everything!

Great. Confirm you have saved the file before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your ReservationsViewModel.swift contains these sections in order:

  • The ViewState enum with four cases (idle, loading, loaded, error).
  • The class declaration with @MainActor, five private properties, onStateChange closure, and the init(service:).
  • The loadReservations() and loadNextPageIfNeeded(currentIndex:) methods.
  • The formattedReservation computed closure and the closing } brace.
Refactor the ViewController to a thin UI shell

Now that the ViewModel owns all logic, the ViewController can shrink to pure UI work. It receives the ViewModel, sets up views, binds to state changes, and renders them. Nothing else.

  • Open ReservationsViewController.swift and replace its entire contents with this class shell:
import UIKit

final class ReservationsViewController: UIViewController {
    private let viewModel: ReservationsViewModel
    private let tableView = UITableView()
    private let activityIndicator = UIActivityIndicatorView(style: .large)
    private let errorLabel = UILabel()
    private let retryButton = UIButton(type: .system)

    init(viewModel: ReservationsViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Reservations"
        view.backgroundColor = .systemBackground
        setupUI()
        bindViewModel()
        Task { await viewModel.loadReservations() }
    }

What changed here?

The ViewController now stores a ReservationsViewModel instead of owning any data arrays or state flags. The viewDidLoad calls three focused methods: set up the UI, bind to state changes, and kick off the first load.

  • Add the setupUI() method below viewDidLoad:
    private func setupUI() {
        tableView.frame = view.bounds
        tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)

        activityIndicator.center = view.center
        activityIndicator.hidesWhenStopped = true
        view.addSubview(activityIndicator)

        errorLabel.textAlignment = .center
        errorLabel.numberOfLines = 0
        errorLabel.isHidden = true
        errorLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(errorLabel)

        retryButton.setTitle("Retry", for: .normal)
        retryButton.isHidden = true
        retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
        retryButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(retryButton)

        NSLayoutConstraint.activate([
            errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -20),
            errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            retryButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            retryButton.topAnchor.constraint(equalTo: errorLabel.bottomAnchor, constant: 12)
        ])
    }
  • Add the state-binding and rendering logic below setupUI():
    private func bindViewModel() {
        viewModel.onStateChange = { [weak self] state in
            self?.render(state: state)
        }
    }

    private func render(state: ViewState) {
        switch state {
        case .idle:
            break
        case .loading:
            activityIndicator.startAnimating()
            tableView.isHidden = true
            errorLabel.isHidden = true
            retryButton.isHidden = true
        case .loaded:
            activityIndicator.stopAnimating()
            tableView.isHidden = false
            errorLabel.isHidden = true
            retryButton.isHidden = true
            tableView.reloadData()
        case .error(let message):
            activityIndicator.stopAnimating()
            tableView.isHidden = true
            errorLabel.text = message
            errorLabel.isHidden = false
            retryButton.isHidden = false
        }
    }

    @objc private func retryTapped() {
        Task { await viewModel.loadReservations() }
    }
}

What does the render method do?

The render(state:) method is a pure switch on the ViewState enum. Each case shows or hides the appropriate UI elements. The ViewController never decides WHAT state to be in. It only responds to the ViewModel telling it.

  • Add the UITableView extension below the closing brace of the class:
extension ReservationsViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.reservations.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let reservation = viewModel.reservations[indexPath.row]
        let formatted = viewModel.formattedReservation(reservation)

        var content = cell.defaultContentConfiguration()
        content.text = formatted.title
        content.secondaryText = formatted.subtitle
        content.secondaryTextProperties.color = formatted.statusColor as? UIColor ?? .secondaryLabel
        cell.contentConfiguration = content
        return cell
    }

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        Task { await viewModel.loadNextPageIfNeeded(currentIndex: indexPath.row) }
    }
}

What does this code do?

The table data source asks the ViewModel for data and formatting. The willDisplay delegate method triggers the ViewModel's pagination check as cells appear. The ViewController never tracks page numbers or loading flags.

Update SceneDelegate to wire the ViewModel

The ViewController now requires a ViewModel in its initializer. You need to update SceneDelegate.swift to create the service, the ViewModel, and pass it in.

  • Open SceneDelegate.swift and replace its contents with:
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        // Wire up the ViewModel with its service dependency
        let service = ReservationService()
        let viewModel = ReservationsViewModel(service: service)
        let viewController = ReservationsViewController(viewModel: viewModel)
        window?.rootViewController = UINavigationController(rootViewController: viewController)
        window?.makeKeyAndVisible()
    }
}

What does this code do?

The SceneDelegate creates a concrete ReservationService(), passes it into the ViewModel, and passes the ViewModel into the ViewController. All the wiring happens in one place.

  • Save both ReservationsViewController.swift and SceneDelegate.swift.

Before you run the app, what do you expect to see? The ViewModel now owns all the logic, but the user-facing behavior should be identical to before.

  • Press Cmd+R to build and run in the Simulator.

The app loads the reservation list, pagination works when you scroll to the bottom, and status colors display correctly. Functionality is identical to before the refactor.

App crashes or shows a blank screen?

  • Check that your ViewController's init(viewModel:) stores the viewModel in the property. Missing self.viewModel = viewModel causes a crash.
  • Confirm your SceneDelegate creates the ViewModel before the ViewController. The ViewController depends on having a ViewModel at init time.
  • Verify the bindViewModel() call comes before the Task { await viewModel.loadReservations() } call in viewDidLoad. If the binding is set up after loading starts, state changes are lost.

Help me debug my ViewController and ViewModel wiring in the ReservationManager app.

What did this refactoring accomplish?

Your ViewController dropped from ~200 lines to ~80 lines. It handles only UI layout and rendering. All business logic (pagination, loading guards, data accumulation) lives in the ViewModel.

However, the ViewModel still receives a concrete ReservationService() created in the SceneDelegate. There is no formal dependency injection container and no tests proving this architecture actually works in isolation. That gap is what the next step closes.

Your architecture is clean, but untested. In the next step, you will add a proper DI container and write unit tests that inject a mock service, proving the refactoring was worth it.

Add Dependency Injection and Write a Unit Test

Your ViewModel accepts its service through the initializer, which means you can swap in any implementation that conforms to ReservationServiceProtocol. But who creates that service and hands it over? Right now the wiring logic lives directly in SceneDelegate with no centralized ownership.

In this step, you will create a Dependency Injection container that owns all object creation, then write unit tests that inject a mock service and verify business logic in complete isolation from UIKit. Passing tests are the proof that your refactoring actually worked.

In this step, get ready to:
  • Create an AppContainer that wires all dependencies in one place.
  • Update SceneDelegate to use the container instead of manual wiring.
  • Write three unit tests with a mock service that prove the architecture is testable.
Create the DI container

A DI container is a single object responsible for creating and wiring all the pieces of your app. Instead of scattered instantiation logic, one container owns those decisions.

  • Create a new Swift file called AppContainer.swift in the ReservationManager group by right-clicking the group in the Navigator, selecting New File, choosing Swift File, and naming it AppContainer.
  • Replace the template contents with this code:
import Foundation

@MainActor
final class AppContainer {
    // Lazy so the service is created only when first needed
    lazy var reservationService: ReservationServiceProtocol = ReservationService()

    func makeReservationsViewController() -> ReservationsViewController {
        // Wire the ViewModel with the shared service instance
        let viewModel = ReservationsViewModel(service: reservationService)
        return ReservationsViewController(viewModel: viewModel)
    }
}

What does this code do?

The container uses a lazy property so the ReservationService is created once and reused across the app. This single instance is shared by every ViewModel the container creates.

The makeReservationsViewController() factory method wires the service into the ViewModel, then the ViewModel into the ViewController. One call produces a fully configured screen.

  • Save AppContainer.swift.
Update SceneDelegate to use AppContainer

Now you need to tell your app to use the container instead of constructing everything inline in SceneDelegate.

  • Switch back to SceneDelegate.swift and replace its entire contents with this code:
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    // Single container owns all dependency creation
    private let container = AppContainer()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        // Container builds the fully-wired ViewController
        let viewController = container.makeReservationsViewController()
        window?.rootViewController = UINavigationController(rootViewController: viewController)
        window?.makeKeyAndVisible()
    }
}

What changed here?

Instead of creating ReservationService and ReservationsViewModel directly, SceneDelegate asks the container for a ready-made ViewController. The container decides which concrete service to inject.

This means you can swap the real service for a mock anywhere the container is configured. That is the power of DI.

  • Save SceneDelegate.swift.
  • Press Cmd+R to run the app in Simulator.

The app should behave exactly as before: reservations load, pagination works, and status colors display correctly. The only difference is internal.

App crashes on launch?

  • Make sure AppContainer.swift is added to the ReservationManager target. Check the file's Target Membership in the File Inspector on the right panel.
  • Confirm makeReservationsViewController() returns ReservationsViewController (not a plain UIViewController).

Still stuck? Help me debug my AppContainer crash.

Write unit tests with a mock service

This is the payoff. Because the ViewModel depends on a protocol (not a concrete class), you can inject a fake service that returns predictable data. Your tests run instantly with no network delay and no randomness.

  • Your project should already have a ReservationManagerTests group (Xcode creates one when you check Include Tests during project setup). If you do not have it, add a test target by selecting File > New > Target, choosing Unit Testing Bundle, and clicking Finish.
  • Create a new Swift file inside the ReservationManagerTests group called ReservationsViewModelTests.swift.
  • Add the mock service and test class setup by pasting this code:
import XCTest
@testable import ReservationManager

// A fake service that returns whatever you tell it to
final class MockReservationService: ReservationServiceProtocol {
    var mockResult: Result<[Reservation], Error> = .success([])

    func fetchReservations(page: Int) async throws -> [Reservation] {
        switch mockResult {
        case .success(let reservations):
            return reservations
        case .failure(let error):
            throw error
        }
    }
}

@MainActor
final class ReservationsViewModelTests: XCTestCase {
    private var mockService: MockReservationService!
    private var viewModel: ReservationsViewModel!

    override func setUp() {
        super.setUp()
        mockService = MockReservationService()
        viewModel = ReservationsViewModel(service: mockService)
    }
}

What does this code do?

MockReservationService conforms to ReservationServiceProtocol but never hits the network. You control its output by setting mockResult to either .success or .failure.

The setUp() method runs before each test, giving you a fresh mock and a fresh ViewModel every time. No shared state leaks between tests.

  • Now add the first test method inside the ReservationsViewModelTests class, below setUp:
    func testLoadReservationsSuccess() async {
        let expectedReservations = [
            Reservation(
                id: "1",
                guestName: "Alice Johnson",
                propertyName: "Grand Hotel",
                checkIn: Date(),
                checkOut: Date().addingTimeInterval(86400),
                status: .confirmed,
                totalAmount: 250.00
            )
        ]
        mockService.mockResult = .success(expectedReservations)

        await viewModel.loadReservations()

        XCTAssertEqual(viewModel.state, .loaded)
        XCTAssertEqual(viewModel.reservations.count, 1)
        XCTAssertEqual(viewModel.reservations.first?.guestName, "Alice Johnson")
    }

What does this test prove?

It proves that when the service returns data, the ViewModel transitions to .loaded state and stores the reservations. No UIKit involved. No network call. Pure logic verification.

  • Add the error test and pagination test below testLoadReservationsSuccess, still inside the class:
    func testLoadReservationsError() async {
        mockService.mockResult = .failure(ReservationServiceError.networkFailure)

        await viewModel.loadReservations()

        if case .error(let message) = viewModel.state {
            XCTAssertTrue(message.contains("Failed to load"))
        } else {
            XCTFail("Expected error state")
        }
        XCTAssertTrue(viewModel.reservations.isEmpty)
    }

    func testPaginationLoadsNextPage() async {
        let firstPage = [
            Reservation(id: "1", guestName: "Alice",
                        propertyName: "Hotel A",
                        checkIn: Date(), checkOut: Date(),
                        status: .confirmed, totalAmount: 100)
        ]
        mockService.mockResult = .success(firstPage)

        await viewModel.loadReservations()
        XCTAssertEqual(viewModel.reservations.count, 1)

        let secondPage = [
            Reservation(id: "2", guestName: "Bob",
                        propertyName: "Hotel B",
                        checkIn: Date(), checkOut: Date(),
                        status: .pending, totalAmount: 200)
        ]
        mockService.mockResult = .success(secondPage)

        await viewModel.loadNextPageIfNeeded(currentIndex: 0)
        XCTAssertEqual(viewModel.reservations.count, 2)
    }

What do these tests prove?

testLoadReservationsError verifies that a network failure puts the ViewModel into an error state with the correct message. It also confirms no stale data leaks through.

testPaginationLoadsNextPage loads two pages sequentially and verifies the ViewModel accumulates both results. This tests pagination logic without waiting for any real network delay.

  • Save ReservationsViewModelTests.swift.

Before you run the tests, predict: will all three pass on the first try, or will one fail?

  • Press Cmd+U to run all unit tests.

You should see a green checkmark next to each of the three test methods in the Test Navigator. All three tests pass.

Tests not running or failing to compile?

  • Check that ReservationsViewModelTests.swift belongs to the ReservationManagerTests target (not the main app target). Open the File Inspector and verify Target Membership.
  • If you see a "No such module" error, ensure the test target's Host Application is set to ReservationManager in the test target's General settings.
  • If a test fails with a concurrency error, confirm both the test class and the ViewModel use @MainActor.

Still stuck? Help me fix my unit test compilation or runtime errors.

Why this proves the refactoring worked

These tests import zero UIKit code. They exercise your business logic (loading, error handling, pagination) in complete isolation by injecting a mock service through the same protocol boundary your app uses.

If any test fails in the future, you know the problem is in your ViewModel logic, not in the UI or the network. That is the payoff of clean architecture.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that Cmd+U shows three green checkmarks.

ⓧ I'd like to double check the full code

Your test file has four sections you built above. Scroll up and confirm each is present in this order:

  • The import lines and MockReservationService class.
  • The ReservationsViewModelTests class with setUp().
  • testLoadReservationsSuccess.
  • testLoadReservationsError and testPaginationLoadsNextPage.

Here is the complete AppContainer.swift for reference:

import Foundation

@MainActor
final class AppContainer {
    lazy var reservationService: ReservationServiceProtocol = ReservationService()

    func makeReservationsViewController() -> ReservationsViewController {
        let viewModel = ReservationsViewModel(service: reservationService)
        return ReservationsViewController(viewModel: viewModel)
    }
}

And the complete SceneDelegate.swift:

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private let container = AppContainer()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        let viewController = container.makeReservationsViewController()
        window?.rootViewController = UINavigationController(rootViewController: viewController)
        window?.makeKeyAndVisible()
    }
}

You just proved that your refactored architecture works. Three passing tests, zero UIKit in the test target, and a DI container that makes swapping implementations trivial. In your interview, this is the moment you tell Jens: the architecture is testable because every dependency flows through a protocol boundary.

Secret mission

Add Offline Caching with a Repository Pattern

Your architecture is clean and testable, but what happens when the network drops? In a real travel app, guests need to see their reservations even without connectivity. Add a Repository layer with in-memory caching that gracefully degrades when the network fails.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your Mac, so there are no ongoing costs.

Resources you used:

  • The ReservationManager Xcode project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or preparing for your interview.

  • Your ReservationManager project stays on your Desktop, ready to open in Xcode any time.
  • Practice the refactoring flow multiple times to build speed before your interview.
  • Consider adding more tests, a custom UITableViewCell, or pull-to-refresh to practice adding features to clean architecture.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your files stay on disk.

  • Stop the Simulator if it is still running by pressing Cmd+. in Xcode or clicking the stop button.
  • Quit Xcode to free up memory.
  • Your project folder remains on your Desktop. Reopen ReservationManager.xcodeproj when you are ready to continue.

Delete - I don't want to use this again

Remove the project entirely. There are no external dependencies or cloud resources to clean up.

  • Stop the Simulator and quit Xcode.
  • Delete the project folder by running this command in Terminal:
rm -rf ~/Desktop/ReservationManager
  • Confirm the folder is gone by running:
ls ~/Desktop | grep ReservationManager

You should see no output, confirming the folder has been removed.

Nice Work!

Nice Work!

You just transformed a 200-line Massive ViewController into clean, testable MVVM architecture with five focused files and passing unit tests. That is exactly the skill Expedia's refactoring interview evaluates.

You've learned how to:

  • Refactored a tangled ViewController into protocol-based networking and a ViewModel with explicit state management that keeps UIKit out of your business logic.
  • Applied constructor-based dependency injection so every layer can be swapped with a mock in one line, proving testability is an architecture decision, not an afterthought.
  • Wrote passing XCTests that verify business logic in complete isolation from UIKit, using a MockReservationService injected through the protocol boundary.
  • Secret Mission: Added a Repository pattern with in-memory caching that gracefully returns cached reservations when the network fails.

Ready to quiz yourself?