iOS App with Actor-Based Caching

Build a music search app with MVVM, Swift actors, and async/await.

Introduction

30 Second Summary

When you interview for a senior iOS role, interviewers care less about flashy features and more about the code underneath. They want to see how you handle concurrency, shared state, and testability.

In this project, you will build a music search app that queries Apple's iTunes Search API. It looks simple on the surface, but demonstrates the exact architecture and concurrency patterns that senior interviewers probe for.

What You'll Build

Type a search term into a SwiftUI app, watch real music results appear with album art and metadata, then walk an interviewer through exactly why you chose actors over locks and protocols over concrete types.

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

  • A live music search app where you type a query and see real results with album art from Apple's iTunes Search API, backed by a clean MVVM + Repository architecture you can explain layer by layer.
  • An actor-based cache and debounced input that prove you understand Swift concurrency. Search "Taylor" twice and the second result appears instantly with zero network requests.
  • Unit tests using Swift Testing that validate your caching logic through protocol-based dependency injection. Run them and see green checkmarks proving every layer works independently.
  • Secret Mission: use TaskGroup to fetch music, movies, and podcasts in parallel and merge results into a single categorized list.

Are there any prerequisites?

You need Xcode 26.6 installed on macOS. Basic familiarity with Swift is helpful, but returning after a break is completely fine.

The architectural patterns will be familiar if you have experience with other frameworks. Each step provides the exact Swift syntax you need.

Before We Start

Before we write any code, let's commit to what you're building and why. Interviewers care far more about how you structure a small app than how many features you can cram into a large one.

Create the Xcode Project and Define the Data Layer

Before you can demonstrate any architecture, you need the project skeleton and data models that represent the API contract.

In this step, you will create the Xcode project and define the data layer that maps directly to what the iTunes Search API returns. These models are the foundation that every other layer builds on.

In this step, get ready to:
  • Create an Xcode project configured for SwiftUI and Swift Testing.
  • Define Codable data models representing the iTunes Search API response.
  • Create an error type for network failures.
Create a new Xcode project

You need an iOS App project with SwiftUI as the interface and Swift Testing selected as the testing framework.

  • Press Cmd+Space to open Spotlight.
  • Type Xcode and press Enter to open it.
  • In the Welcome window, click Create New Project.
  • Make sure iOS is selected at the top, then select App and click Next.
  • Set the Product Name to MusicSearch.
  • Confirm Interface is set to SwiftUI.
  • Set Storage to None.
  • Set the Testing System pop-up to Swift Testing with XCTest UI Tests.
  • Click Next, choose your Desktop as the save location, and click Create.

Xcode opens the new project with a default ContentView.swift displaying a "Hello, world!" preview.

  • Build the project by pressing Cmd+B.

You should see Build Succeeded in the activity area at the top of the Xcode window.

Build failed on a fresh project?

Make sure you selected iOS as the platform (not macOS or multiplatform).

Check that a simulator is selected in the destination bar at the top. If no simulator appears, go to Window > Devices and Simulators and confirm an iOS simulator is available.

Why won't my new Xcode project build?

Define the data models

The iTunes Search API returns JSON with a resultCount and a results array of track objects. You need Swift structs that mirror this shape so the JSON decoder can map the response directly.

  • Create a new Swift file by pressing Cmd+N.
  • Select Swift File and click Next.
  • Name the file Models and click Create.
  • Replace the contents of Models.swift with the following code:
import Foundation

// Represents the top-level JSON response from the iTunes Search API
struct SearchResponse: Codable {
    let resultCount: Int
    let results: [Track]
}

// Represents a single track in the search results
struct Track: Codable, Identifiable {
    let trackId: Int
    let trackName: String
    let artistName: String
    let artworkUrl100: String
    let previewUrl: String?

    // Identifiable conformance lets SwiftUI use this in List/ForEach
    var id: Int { trackId }
}

What does this code do?

  • SearchResponse mirrors the top-level JSON shape returned by the API, with a count and an array of tracks.
  • Track conforms to Codable so Swift can automatically decode it from JSON.
  • The Identifiable conformance (using trackId as the id) lets SwiftUI efficiently render tracks in a List without needing explicit IDs.
  • previewUrl is optional because not every track has a preview available.
  • Save the file with Cmd+S.
  • Build with Cmd+B to confirm the models compile.

You should see Build Succeeded with no warnings or errors.

Seeing a compiler error?

Check that each property name matches exactly (e.g. artworkUrl100 not artworkURL100). The JSON decoder is case-sensitive to these keys.

Make sure the file target membership is set to MusicSearch (check the File Inspector on the right sidebar).

Help me fix a compiler error in my Models.swift file

✔️ Awesome, I've got everything!

Great. Make sure you have saved Models.swift and the build succeeded before moving on.

ⓧ I'd like to double check the full code

import Foundation

struct SearchResponse: Codable {
    let resultCount: Int
    let results: [Track]
}

struct Track: Codable, Identifiable {
    let trackId: Int
    let trackName: String
    let artistName: String
    let artworkUrl100: String
    let previewUrl: String?

    var id: Int { trackId }
}
Create the network error type

When network calls fail, you want typed errors that tell you exactly what went wrong. A custom error enum keeps error handling clean and predictable throughout the app.

  • Create another new Swift file with Cmd+N.
  • Select Swift File and click Next.
  • Name the file NetworkError and click Create.
  • Replace the contents of NetworkError.swift with the following code:
import Foundation

// Defines the possible network failure modes
enum NetworkError: Error {
    case invalidURL
    case requestFailed
    case decodingFailed
}

What does this code do?

  • NetworkError conforms to Swift's Error protocol, making it throwable from async functions.
  • invalidURL covers the case where the search term cannot be encoded into a valid URL.
  • requestFailed handles network connectivity issues.
  • decodingFailed fires when the API response does not match the expected Codable shape.

Before you build, what do you expect to see? You have two new Swift files with no dependencies on anything outside Foundation.

  • Save the file with Cmd+S.
  • Build the project with Cmd+B.

You should see Build Succeeded with zero errors and zero warnings. Both files compile cleanly because they depend only on Foundation.

Build still failing?

Open the Issue Navigator (Cmd+5) to see exact error locations. The most common cause is a typo in the enum case names or a missing colon after Error.

Help me fix build errors in my MusicSearch project

✔️ Awesome, I've got everything!

Great. Make sure both files are saved and your build shows zero errors before continuing.

ⓧ I'd like to double check the full code

import Foundation

enum NetworkError: Error {
    case invalidURL
    case requestFailed
    case decodingFailed
}

Your data layer is locked in. Next up, you will build the naive search feature that actually calls the iTunes API and displays results on screen.

Build the Naive Search

Your project has data models and error types, but the app itself does nothing yet.

In this step, you'll build the first working version that fetches real music data from Apple's iTunes Search API. This version intentionally fires a network request on every single keystroke. You need to feel that problem before you can fix it in the next step.

In this step, get ready to:
  • Create a network service that fetches music from the iTunes API.
  • Create a ViewModel that connects the search text to the service.
  • Build the UI and run the app to see the per-keystroke problem.
Create the music service

The service layer is responsible for making HTTP requests to the iTunes Search API and decoding the JSON response into your Track model. You'll define a protocol first so the concrete implementation can be swapped out for a mock in tests later.

  • In the Xcode Project Navigator, right-click the MusicSearch folder and select New File... to create a new Swift file.
  • Name the file MusicService.swift and click Create.
  • Replace the file contents with this code:
import Foundation

// Protocol enables swapping in a mock for testing
protocol MusicServiceProtocol: Sendable {
    func search(term: String) async throws -> [Track]
}

final class MusicService: MusicServiceProtocol {
    func search(term: String) async throws -> [Track] {
        // Build the iTunes Search API URL with query parameters
        guard let encodedTerm = term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
              let url = URL(string: "https://itunes.apple.com/search?term=\(encodedTerm)&country=US&media=music&limit=25") else {
            throw NetworkError.invalidURL
        }

        // Log every network request so you can see the per-keystroke problem
        print("[Network] Fetching: \(term)")

        let (data, _) = try await URLSession.shared.data(from: url)

        do {
            let response = try JSONDecoder().decode(SearchResponse.self, from: data)
            return response.results
        } catch {
            throw NetworkError.decodingFailed
        }
    }
}

What does this code do?

MusicServiceProtocol defines the contract for any search service. The Sendable conformance is required because this protocol will be used across concurrency boundaries (passed into an actor-isolated repository later).

The concrete MusicService builds a URL with the search term, fetches data using URLSession.shared.data(from:), and decodes the response. The print statement logs each request so you can watch the console and count how many times the API gets hit.

  • Save MusicService.swift.
  • Press Cmd+B to build the project. You should see Build Succeeded in the status bar with zero errors.

Seeing a build error?

Make sure MusicService.swift is in the same target as Models.swift. Check by selecting the file, opening the File Inspector (right panel), and confirming MusicSearch is checked under Target Membership.

help me fix a build error in MusicService.swift

Create the search ViewModel

The ViewModel holds the search text and the results array. It calls the service every time performSearch() is invoked. Later, SwiftUI's .task(id:) modifier will call this method on every keystroke.

  • Create another new Swift file named SearchViewModel.swift in the MusicSearch folder.
  • Replace the file contents with this code:
import SwiftUI

@Observable
class SearchViewModel {
    var searchText: String = ""
    var tracks: [Track] = []
    var errorMessage: String?

    private let service: MusicServiceProtocol

    // Inject the service so it can be swapped for a mock in tests
    init(service: MusicServiceProtocol) {
        self.service = service
    }

    // Called on every searchText change via .task(id:)
    func performSearch() async {
        guard !searchText.isEmpty else {
            tracks = []
            return
        }
        do {
            let results = try await service.search(term: searchText)
            tracks = results
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

What does this code do?

The @Observable macro makes every stored property automatically trigger view updates when it changes. This is the modern replacement for ObservableObject and @Published.

The guard !searchText.isEmpty check clears results when the field is empty. The init accepts any MusicServiceProtocol conforming type, which is the protocol-based dependency injection pattern.

  • Save SearchViewModel.swift.
  • Press Cmd+B to build. You should see Build Succeeded again with zero errors.

Build failed on @Observable?

The @Observable macro requires a minimum deployment target of iOS 17. Check your project settings by clicking the project name in the Navigator, selecting the MusicSearch target, and confirming the Minimum Deployments value is iOS 17.0 or higher.

help me fix an @Observable build error in my Xcode project

Build the view and wire up the app

Now you need the UI layer. The ContentView displays a search bar and a list of tracks. SwiftUI's .task(id: viewModel.searchText) modifier triggers performSearch() every time the search text changes. This is what creates the per-keystroke firing.

  • Open the existing ContentView.swift file that Xcode generated when you created the project.
  • Select all the existing code and replace it with the view structure and row layout:
import SwiftUI

struct ContentView: View {
    @State private var viewModel: SearchViewModel

    // Accept the viewModel for dependency injection
    init(viewModel: SearchViewModel) {
        _viewModel = State(initialValue: viewModel)
    }

    var body: some View {
        NavigationStack {
            List(viewModel.tracks) { track in
                HStack(spacing: 12) {
                    // Load album artwork from URL
                    AsyncImage(url: URL(string: track.artworkUrl100)) { image in
                        image.resizable()
                    } placeholder: {
                        Color.gray.opacity(0.3)
                    }
                    .frame(width: 50, height: 50)
                    .cornerRadius(8)

                    VStack(alignment: .leading, spacing: 4) {
                        Text(track.trackName)
                            .font(.headline)
                            .lineLimit(1)
                        Text(track.artistName)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
            }
  • Directly below the closing } of the List, add the navigation and search modifiers that complete the view:
            // Search bar bound to viewModel.searchText
            .navigationTitle("Music Search")
            .searchable(text: $viewModel.searchText, prompt: "Search songs...")
            // Fires performSearch() on EVERY change to searchText
            .task(id: viewModel.searchText) {
                await viewModel.performSearch()
            }
        }
    }
}

What does .task(id:) do?

The .task(id: viewModel.searchText) modifier watches searchText. Every time its value changes, SwiftUI cancels the previous task and starts a new one. Right now, each new task immediately fires a network request. That means typing "Taylor" produces six requests: "T", "Ta", "Tay", "Tayl", "Taylo", "Taylor".

Finally, you need to update the app entry point to inject the real service into the ViewModel.

  • Open MusicSearchApp.swift from the Project Navigator.
  • Replace the entire file contents with this code:
import SwiftUI

@main
struct MusicSearchApp: App {
    var body: some Scene {
        WindowGroup {
            // Inject the real service into the ViewModel
            ContentView(viewModel: SearchViewModel(
                service: MusicService()
            ))
        }
    }
}

What changed here?

The default Xcode template used ContentView() with no arguments. You replaced it with ContentView(viewModel: SearchViewModel(service: MusicService())) to wire up the dependency chain: the App creates the service, passes it to the ViewModel, and passes the ViewModel to the View.

  • Save all files (Cmd+S).

Before you run the app, think about what will happen when you type "Taylor" in the search field. How many network requests do you expect to see in the console?

  • Press Cmd+R to build and run the app in the Simulator.
  • Tap the search field at the top and type Taylor.
  • Switch back to Xcode and check the console output at the bottom of the screen (the Debug area).

You'll see a log entry for every single character you typed:

[Network] Fetching: T
[Network] Fetching: Ta
[Network] Fetching: Tay
[Network] Fetching: Tayl
[Network] Fetching: Taylo
[Network] Fetching: Taylor

That is six network requests just for one word. Results appear but flicker with each keystroke as old responses get replaced by new ones. The iTunes API allows approximately 20 calls per minute, so rapid typing could easily exhaust that limit.

Why is this a problem?

Each keystroke fires a full HTTP round-trip. This wastes bandwidth, hammers the API rate limit, and causes the UI to flicker as stale responses arrive out of order. In a real production app, this would degrade both performance and user experience.

This is the exact problem the next step solves with debouncing and Task cancellation.

Not seeing any console output?

Make sure the Debug area is visible by pressing Cmd+Shift+Y in Xcode. Also confirm you are typing in the search bar inside the Simulator, not the Xcode search field.

help me see console output when running my SwiftUI app in Xcode

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files: MusicService.swift, SearchViewModel.swift, ContentView.swift, and MusicSearchApp.swift.

ⓧ I'd like to double check the full code

Your project now has four key files. Scroll up and confirm each code block is present in your project, in order:

  • MusicService.swift with the protocol and class.
  • SearchViewModel.swift with the @Observable class.
  • ContentView.swift with the NavigationStack, List, and .task(id:) modifier.
  • MusicSearchApp.swift with the dependency injection wiring.

Your app works end-to-end, fetching real music from Apple's servers. But you've just seen the cost: one request per keystroke, flickering results, and a path straight to rate limiting. Next up, you'll add debouncing and Task cancellation to fire only a single request after the user stops typing.

Add Debouncing and Task Cancellation

In the last step, you saw the problem firsthand. Typing "Taylor" fired six separate network requests. The iTunes API has a rate limit of approximately 20 calls per minute, so rapid typing could exhaust that limit and produce flickering results.

The fix is debouncing. You add a short delay before the network call. If the user types another character during that delay, SwiftUI cancels the previous task and starts a new one. Only the final keystroke survives to actually fire a request.

In this step, get ready to:
  • Add a 300ms debounce delay using cooperative Task cancellation.
  • Handle CancellationError silently so intermediate keystrokes don't produce error messages.
  • Add error and empty-state overlays to the UI.
Update performSearch() with debouncing

The .task(id:) modifier in your ContentView already does something powerful. When searchText changes, SwiftUI cancels the previous task and starts a new one. If you add a sleep at the start of the task, intermediate keystrokes throw a CancellationError during the sleep, and only the final keystroke's task survives past the delay.

  • In SearchViewModel.swift, find the current performSearch() method:
func performSearch() async {
    guard !searchText.isEmpty else {
        tracks = []
        return
    }
    do {
        let results = try await service.search(term: searchText)
        tracks = results
    } catch {
        errorMessage = error.localizedDescription
    }
}
  • Replace the entire method with this updated version:
func performSearch() async {
    guard !searchText.isEmpty else {
        tracks = []
        errorMessage = nil
        return
    }

    do {
        // Wait 300ms before firing the request
        try await Task.sleep(for: .milliseconds(300))
        let results = try await service.search(term: searchText)
        tracks = results
        errorMessage = nil
    } catch is CancellationError {
        // Expected when user types quickly - previous task cancelled
    } catch {
        errorMessage = error.localizedDescription
    }
}

What does this code do?

The debouncing works through cooperation between .task(id:) and Task.sleep:

  • Task.sleep(for: .milliseconds(300)) pauses for 300 milliseconds. If the task gets cancelled during this pause (because the user typed another character), it throws CancellationError.
  • catch is CancellationError handles that cancellation silently. This is expected behavior, not an error.
  • Only the task that survives the full 300ms sleep reaches the actual network call.

Before you run the app, think about this: if you type "Taylor" quickly (6 characters), how many [Network] Fetching: logs do you expect to see in the console?

  • Save SearchViewModel.swift.
  • Run the app with Cmd+R.
  • Type "Taylor" quickly in the search field.

You should see exactly ONE [Network] Fetching: Taylor log in the console. Not six. The five intermediate keystrokes were cancelled during the 300ms sleep.

Still seeing multiple network logs?

  • Make sure the Task.sleep line comes BEFORE the service.search call inside the do block.
  • Confirm you're using try await Task.sleep(for: .milliseconds(300)) with both try and await keywords.
  • Check that your .task(id: viewModel.searchText) modifier in ContentView hasn't been changed. The id parameter is what triggers cancellation.

Help me debug why debouncing isn't working in my SwiftUI search

Add error and empty-state overlays to ContentView

Now that errorMessage is properly set for real errors and cleared on success, the UI should display those states to the user.

  • In ContentView.swift, find the .task(id: viewModel.searchText) modifier at the bottom of the List.
  • Add this overlay modifier directly below it:
// Show error or empty-state messages over the list
.overlay {
    if let error = viewModel.errorMessage {
        ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error))
    } else if viewModel.tracks.isEmpty && !viewModel.searchText.isEmpty {
        ContentUnavailableView.search(text: viewModel.searchText)
    }
}

What does this overlay do?

  • If errorMessage has a value, it shows an error icon with the message.
  • If the tracks array is empty but the user has typed something, it shows a "No results" search state.
  • ContentUnavailableView is a built-in SwiftUI view designed for exactly these placeholder states.
  • Save ContentView.swift and run the app with Cmd+R.
  • Type a nonsense string like "xzxzxzxz" in the search field and wait for the debounce to fire.

You should see a "No Results" placeholder with a magnifying glass icon. The overlay is working.

Not seeing the empty-state overlay?

  • Make sure the .overlay modifier is chained on the List (same level as .navigationTitle and .searchable), not inside the NavigationStack.
  • Check that the condition uses viewModel.tracks.isEmpty (not viewModel.searchText.isEmpty) for the second branch.

Help me fix my ContentUnavailableView overlay placement

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full SearchViewModel.swift:

import SwiftUI

@Observable
class SearchViewModel {
    var searchText: String = ""
    var tracks: [Track] = []
    var errorMessage: String?

    private let service: MusicServiceProtocol

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

    func performSearch() async {
        guard !searchText.isEmpty else {
            tracks = []
            errorMessage = nil
            return
        }

        do {
            try await Task.sleep(for: .milliseconds(300))
            let results = try await service.search(term: searchText)
            tracks = results
            errorMessage = nil
        } catch is CancellationError {
            // Expected when user types quickly - previous task cancelled
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

And the full ContentView.swift:

import SwiftUI

struct ContentView: View {
    @State private var viewModel: SearchViewModel

    init(viewModel: SearchViewModel) {
        _viewModel = State(initialValue: viewModel)
    }

    var body: some View {
        NavigationStack {
            List(viewModel.tracks) { track in
                HStack(spacing: 12) {
                    AsyncImage(url: URL(string: track.artworkUrl100)) { image in
                        image.resizable()
                    } placeholder: {
                        Color.gray.opacity(0.3)
                    }
                    .frame(width: 50, height: 50)
                    .cornerRadius(8)

                    VStack(alignment: .leading, spacing: 4) {
                        Text(track.trackName)
                            .font(.headline)
                            .lineLimit(1)
                        Text(track.artistName)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .navigationTitle("Music Search")
            .searchable(text: $viewModel.searchText, prompt: "Search songs...")
            .task(id: viewModel.searchText) {
                await viewModel.performSearch()
            }
            .overlay {
                if let error = viewModel.errorMessage {
                    ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error))
                } else if viewModel.tracks.isEmpty && !viewModel.searchText.isEmpty {
                    ContentUnavailableView.search(text: viewModel.searchText)
                }
            }
        }
    }
}

Why this approach over Combine debouncing?

You might expect to use Combine's .debounce(for:) operator here. The .task(id:) + Task.sleep pattern achieves the same result without importing Combine or managing publishers.

This is pure Swift Concurrency. SwiftUI handles the cancellation lifecycle for you. In an interview, this demonstrates you understand cooperative cancellation at the language level, not just framework convenience methods.

Your app now respects the API rate limit and delivers a smooth user experience. Next up, you'll add an actor-based cache so repeated searches return instantly without any network call at all.

Introduce the Actor-Based Cache

Your app now waits for the user to stop typing before firing a request. That solved the per-keystroke spam.

But there is still a waste hiding in plain sight. Search for "Taylor", then clear the field and search "Taylor" again. You will see two [Network] Fetching: Taylor logs in Xcode's console. The second request fetches data you already have. An actor solves this by providing a thread-safe in-memory cache with zero locks or dispatch queues.

In this step, get ready to:
  • Create a SearchCache actor that stores previous search results.
  • Build a MusicRepository that checks the cache before calling the network.
  • Rewire SearchViewModel to depend on the repository instead of the service directly.
Create the SearchCache actor

A Swift actor is a reference type that serializes access to its mutable state. Any code that reads or writes the actor's properties must await, and the compiler enforces this at build time. No locks, no dispatch queues, no data races.

  • Create a new Swift file called SearchCache.swift in the MusicSearch group.
  • Add the following code:
import Foundation

actor SearchCache {
    // Dictionary mapping search terms to their results
    private var cache: [String: [Track]] = [:]

    func cachedResults(for term: String) -> [Track]? {
        cache[term]
    }

    func store(results: [Track], for term: String) {
        cache[term] = results
    }
}

What does this code do?

The actor keyword makes SearchCache an actor-isolated type. Swift guarantees that only one task can access cache at a time, eliminating data races without manual locking.

  • cachedResults(for:) returns stored tracks for a term, or nil if nothing is cached yet.
  • store(results:for:) saves a fresh response so future lookups skip the network.
  • Save SearchCache.swift.
  • Press Cmd+B to build. The project should compile with zero errors.

Seeing a compile error?

  • Make sure the file is inside the MusicSearch target, not the Tests target. Check the file's Target Membership in the File Inspector (right panel).
  • Confirm you typed actor (not class or struct).

Help me fix a compile error in my SearchCache actor

Create the MusicRepository

The Repository pattern sits between your ViewModel and the network layer. It decides whether to serve data from the cache or fetch fresh data from the service.

  • Create a new Swift file called MusicRepository.swift in the MusicSearch group.
  • Add the following code:
import Foundation

protocol MusicRepositoryProtocol {
    func search(term: String) async throws -> [Track]
}

final class MusicRepository: MusicRepositoryProtocol {
    private let service: MusicServiceProtocol
    private let cache: SearchCache

    init(service: MusicServiceProtocol, cache: SearchCache) {
        self.service = service
        self.cache = cache
    }

    func search(term: String) async throws -> [Track] {
        // Check cache first - returns instantly if we have results
        if let cached = await cache.cachedResults(for: term) {
            print("[Cache] Hit for: \(term)")
            return cached
        }

        // Cache miss - fetch from network and store
        let results = try await service.search(term: term)
        await cache.store(results: results, for: term)
        return results
    }
}

What does this code do?

MusicRepositoryProtocol defines the contract your ViewModel will depend on. This protocol boundary is what makes the architecture testable.

  • The search method checks the actor's cache with await. If results exist, it returns them immediately and prints a log so you can see cache hits.
  • On a cache miss, it calls the network service, stores the results in the actor, and then returns them.
  • Every await call to the cache is the actor isolation boundary at work. Swift handles the serialization for you.
  • Save MusicRepository.swift.
  • Press Cmd+B to build. The project should still compile successfully.

Build failing after adding the repository?

  • Confirm you wrote MusicServiceProtocol (not MusicServiceProtocal or another typo) in the property and initializer.
  • Check that SearchCache is accessible. Both files must be in the same target.

Help me fix my MusicRepository build error

Wire up the ViewModel and app entry point

The cache and repository exist, but nothing uses them yet. You need to swap SearchViewModel's dependency from the service to the repository, then update the app's injection site.

  • Open SearchViewModel.swift.
  • Find this line:
    private let service: MusicServiceProtocol
  • Replace it with:
    private let repository: MusicRepositoryProtocol
  • Find the initializer:
    init(service: MusicServiceProtocol) {
        self.service = service
    }
  • Replace it with:
    init(repository: MusicRepositoryProtocol) {
        self.repository = repository
    }
  • Inside performSearch(), find this line:
            let results = try await service.search(term: searchText)
  • Replace service with repository so it reads:
            let results = try await repository.search(term: searchText)
  • Save SearchViewModel.swift.

Why swap the dependency?

The ViewModel no longer knows about the network at all. It asks the repository for data, and the repository decides whether to hit the cache or the network. This separation is what interviewers look for when they ask about clean architecture.

  • Open MusicSearchApp.swift.
  • Find the existing ContentView initialization:
ContentView(viewModel: SearchViewModel(
    service: MusicService()
))
  • Replace it with the full injection chain:
ContentView(viewModel: SearchViewModel(
    repository: MusicRepository(
        service: MusicService(),
        cache: SearchCache()
    )
))
  • Save MusicSearchApp.swift.
  • Press Cmd+B to build. The project should compile with zero errors.

Seeing an argument mismatch error?

  • The error usually means the initializer label doesn't match. Confirm SearchViewModel's init uses the label repository: (not service:).
  • Make sure you saved both files before building.

Help me fix argument label mismatches in my MusicSearch app

Before you run the app, think about what will happen when you search for "Taylor" twice. Will you see one [Network] log or two?

  • Press Cmd+R to run the app in the Simulator.
  • Type Taylor in the search field. Wait for results to appear.
  • Clear the search field (tap the X).
  • Type Taylor again and watch Xcode's console output.

The first search prints [Network] Fetching: Taylor. The second search prints [Cache] Hit for: Taylor with no network request. Results appear instantly from memory.

✔️ I see [Cache] Hit on the second search

Your actor-based cache is working. The repository served stored data without touching the network.

ⓧ I see two [Network] logs

  • Make sure MusicRepository's search method checks the cache BEFORE calling the service. The if let cached block must come first.
  • Confirm the app entry point in MusicSearchApp.swift injects MusicRepository (not MusicService directly).
  • Verify that SearchViewModel calls repository.search (not service.search).

Help me debug why my SearchCache actor isn't caching results

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: SearchCache.swift, MusicRepository.swift, SearchViewModel.swift, and MusicSearchApp.swift.

ⓧ I'd like to double check the full code

Here is SearchCache.swift in full:

import Foundation

actor SearchCache {
    private var cache: [String: [Track]] = [:]

    func cachedResults(for term: String) -> [Track]? {
        cache[term]
    }

    func store(results: [Track], for term: String) {
        cache[term] = results
    }
}

Here is MusicRepository.swift in full:

import Foundation

protocol MusicRepositoryProtocol {
    func search(term: String) async throws -> [Track]
}

final class MusicRepository: MusicRepositoryProtocol {
    private let service: MusicServiceProtocol
    private let cache: SearchCache

    init(service: MusicServiceProtocol, cache: SearchCache) {
        self.service = service
        self.cache = cache
    }

    func search(term: String) async throws -> [Track] {
        if let cached = await cache.cachedResults(for: term) {
            print("[Cache] Hit for: \(term)")
            return cached
        }

        let results = try await service.search(term: term)
        await cache.store(results: results, for: term)
        return results
    }
}

Here is MusicSearchApp.swift in full:

import SwiftUI

@main
struct MusicSearchApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: SearchViewModel(
                repository: MusicRepository(
                    service: MusicService(),
                    cache: SearchCache()
                )
            ))
        }
    }
}

For SearchViewModel.swift, scroll up and confirm these edits are in place:

  • The stored property is private let repository: MusicRepositoryProtocol.
  • The initializer is init(repository: MusicRepositoryProtocol).
  • The fetch call is try await repository.search(term: searchText).

Your app now has a production-grade caching layer. The actor guarantees thread safety at compile time, and the protocol boundary makes the entire repository testable with mocks. Next up, you will prove that testability by writing unit tests using Swift Testing.

Add Unit Tests with Swift Testing

Your app now has a clean architecture with protocol boundaries separating each layer. The MusicRepositoryProtocol abstracts the repository, and MusicServiceProtocol abstracts the network layer.

Those protocol boundaries exist for a reason: they let you swap real implementations for fakes during testing. This step proves the architecture is testable by writing unit tests using Swift Testing, the modern framework that interviewers increasingly expect you to know.

In this step, get ready to:
  • Create a mock service that tracks how many times it is called.
  • Write unit tests that verify caching behavior without hitting the network.
  • Run the full test suite and confirm green checkmarks.
Create the mock service

A mock replaces the real network service with something predictable. Instead of calling the iTunes API, the mock returns hardcoded data and counts how many times it was called.

  • In Xcode, expand the MusicSearchTests folder in the Project Navigator (the left sidebar).
  • Right-click the MusicSearchTests folder, then select New File....
  • Choose Swift File and name it MockMusicService.swift.
  • Make sure the file's Target Membership is set to MusicSearchTests (not the main app target).
  • Replace the file contents with this code:
import Testing
@testable import MusicSearch

final class MockMusicService: MusicServiceProtocol {
    var callCount = 0
    var stubbedResults: [Track] = [
        Track(trackId: 1, trackName: "Test Song", artistName: "Test Artist", artworkUrl100: "https://example.com/art.jpg", previewUrl: nil)
    ]

    func search(term: String) async throws -> [Track] {
        callCount += 1
        return stubbedResults
    }
}

What does this code do?

The @testable import MusicSearch line gives the test target access to all internal types from your main app module.

The mock conforms to MusicServiceProtocol so it can be injected anywhere the real service is expected. The callCount property tracks how many times search was called. This is how you prove the cache prevented a second network call.

  • Save MockMusicService.swift.
  • Press Cmd+B to build the project. The build should succeed with no errors.

Seeing a build error?

Check that the file's Target Membership is MusicSearchTests, not the main app. Select the file in the Project Navigator, open the File Inspector (right sidebar), and verify the checkbox.

If you see a "cannot find type" error for MusicServiceProtocol, confirm the @testable import MusicSearch line is present at the top of the file.

Help me fix my MockMusicService build error

Write the first cache test

The first test proves that searching the same term twice only hits the network once. The second call should be served from the actor cache.

  • Right-click the MusicSearchTests folder again and create another Swift File named MusicRepositoryTests.swift.
  • Make sure its Target Membership is MusicSearchTests.
  • Replace the file contents with this code:
import Testing
@testable import MusicSearch

struct MusicRepositoryTests {

    @Test func searchReturnsCachedResultsOnSecondCall() async throws {
        let mockService = MockMusicService()
        let cache = SearchCache()
        let repository = MusicRepository(service: mockService, cache: cache)

        _ = try await repository.search(term: "Taylor")
        _ = try await repository.search(term: "Taylor")

        #expect(mockService.callCount == 1)
    }
}

What does this test prove?

The test creates a MusicRepository with the mock service and a real SearchCache actor. It calls search(term: "Taylor") twice.

If the cache works correctly, the mock's callCount should be 1. The second call hit the cache instead of the mock service. The #expect macro is Swift Testing's assertion. It captures subexpressions on failure so you get a clear error message.

  • Save MusicRepositoryTests.swift.
  • Press Cmd+U to run the test suite. You should see a green checkmark next to searchReturnsCachedResultsOnSecondCall in the Test Navigator.

Test failing with callCount == 2?

If the mock reports 2 calls instead of 1, the cache is not being checked. Confirm your MusicRepository.search(term:) method calls await cache.cachedResults(for: term) before calling the service.

Help me debug my failing cache test

Write the second test for different search terms

The first test proves the cache works for repeated queries. The second test proves the cache is keyed by search term. Different terms should each trigger their own network call.

  • In MusicRepositoryTests.swift, find the closing brace of the searchReturnsCachedResultsOnSecondCall function.
  • Add this second test function below it, inside the MusicRepositoryTests struct:
    @Test func searchWithDifferentTermsCallsServiceTwice() async throws {
        let mockService = MockMusicService()
        let cache = SearchCache()
        let repository = MusicRepository(service: mockService, cache: cache)

        _ = try await repository.search(term: "Taylor")
        _ = try await repository.search(term: "Beatles")

        #expect(mockService.callCount == 2)
    }

What does this test prove?

Searching "Taylor" and then "Beatles" are two different cache keys. The mock should be called twice because neither result was cached when it was first requested. This confirms the cache stores results per term, not as a single global value.

  • Save MusicRepositoryTests.swift.

Before you run the tests, what do you expect to see? Both tests should pass because the repository uses the search term as the cache key.

  • Press Cmd+U to run the full test suite.

You should see two green checkmarks in the Test Navigator. Both tests pass.

Tests not appearing in the Test Navigator?

Make sure both test files have their Target Membership set to MusicSearchTests. Select each file and check the File Inspector (right sidebar).

Also confirm you are using the @Test attribute on each function. Swift Testing discovers tests by this attribute, not by a naming convention.

Help me get my Swift Testing tests to appear

✔️ Awesome, I've got everything!

Great. Double check you have saved both files in the Tests target.

ⓧ I'd like to double check the full code

Here is the full MockMusicService.swift:

import Testing
@testable import MusicSearch

final class MockMusicService: MusicServiceProtocol {
    var callCount = 0
    var stubbedResults: [Track] = [
        Track(trackId: 1, trackName: "Test Song", artistName: "Test Artist", artworkUrl100: "https://example.com/art.jpg", previewUrl: nil)
    ]

    func search(term: String) async throws -> [Track] {
        callCount += 1
        return stubbedResults
    }
}

Here is the full MusicRepositoryTests.swift:

import Testing
@testable import MusicSearch

struct MusicRepositoryTests {

    @Test func searchReturnsCachedResultsOnSecondCall() async throws {
        let mockService = MockMusicService()
        let cache = SearchCache()
        let repository = MusicRepository(service: mockService, cache: cache)

        _ = try await repository.search(term: "Taylor")
        _ = try await repository.search(term: "Taylor")

        #expect(mockService.callCount == 1)
    }

    @Test func searchWithDifferentTermsCallsServiceTwice() async throws {
        let mockService = MockMusicService()
        let cache = SearchCache()
        let repository = MusicRepository(service: mockService, cache: cache)

        _ = try await repository.search(term: "Taylor")
        _ = try await repository.search(term: "Beatles")

        #expect(mockService.callCount == 2)
    }
}

Why does this matter for interviews?

You just demonstrated the full testability loop that senior interviewers look for. You defined a protocol boundary, injected a mock through that boundary, and asserted behavior without touching the network.

The #expect macro from Swift Testing replaces the older XCTAssert family. It produces richer failure messages and requires less boilerplate. Knowing this framework signals you stay current with Apple's tooling.

Your architecture is now provably testable. You have protocol boundaries, dependency injection, and passing tests to back it up. Next up: the Secret Mission challenges you to extend this foundation with parallel multi-category fetching using TaskGroup.

Secret mission

Parallel Multi-Category Search with TaskGroup

Interviewers love asking "how would you fetch multiple resources in parallel?" Extend your app to search music, movies, and podcasts simultaneously using TaskGroup, then merge the results into a single categorized list.

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 and uses the free iTunes Search API, so there are no ongoing costs.

Resources you used:

  • The MusicSearch Xcode project folder on your local filesystem.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep the project as an interview portfolio piece.

  • Your MusicSearch project lives on your local filesystem. It takes minimal disk space and costs nothing to keep.
  • Consider pushing the project to a GitHub repository so you can share it with interviewers or reference it during technical discussions.

Pause - I'll come back to this later

Shut down Xcode to free up memory. Your project files remain on disk for when you return.

  • Close the MusicSearch project in Xcode.
  • Quit Xcode entirely to reclaim system memory.
  • When you're ready to resume, open the MusicSearch.xcodeproj file from your project folder.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • Close the project in Xcode and quit Xcode.
  • Delete the MusicSearch project folder by running this command in Terminal (adjust the path if you saved it elsewhere):
rm -rf ~/Desktop/MusicSearch
  • Confirm the folder is gone by running:
ls ~/Desktop | grep MusicSearch

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

Nice Work!

Nice Work!

You just built a production-grade SwiftUI music search app with the exact architecture and concurrency patterns that senior iOS interviewers probe.

You've learned how to:

  • Build a SwiftUI app with MVVM + Repository architecture, using protocol boundaries and dependency injection to make every layer independently testable.
  • Implement an actor-based in-memory cache that guarantees thread-safe shared state without locks or dispatch queues, and use cooperative Task cancellation with debouncing to prevent redundant API calls.
  • Write unit tests using Swift Testing with #expect macros that validate caching behavior through a mock service and protocol-based dependency injection.
  • Secret Mission: Used TaskGroup for parallel multi-category fetching across music, movies, and podcasts, merging results into a single categorized list.

Ready to quiz yourself?