The practical round Β· build a feature from an API

The take-home, mastered

"Here's an API β€” build a small feature." It sounds like a gift and quietly fails more senior candidates than the whiteboard. The happy path is the easy 20%. The signal is the states nobody demos, the seam that makes it testable, the accessibility you added by reflex, and your ability to defend every call. This page is the reference build β€” do it once until each piece is muscle memory, and the real take-home becomes a formality.

🎯 8 things graders tick πŸ“± SwiftUI + async/await πŸ§ͺ Reference tests & DI πŸ“¦ 100% offline
🎯 By the end of this you can…

Take any small JSON API and ship a SwiftUI feature with all four states (loading / error / empty / loaded), a protocol-injected network layer, unit tests that run offline against a fake, VoiceOver + Dynamic Type support, and a one-paragraph performance note β€” then walk an interviewer through every decision calmly. That last part is the senior signal.

0How to use this

Build alongside, don't skim. Open Xcode and recreate each piece as you read it.

  • Type the code, don't copy it. The reps are the point β€” a take-home is timed, and fluency is what saves you.
  • Reveal the reference only after your attempt. Each section hides a reference implementation behind a spoiler. Try first.
  • Narrate as you go. Many practicals end in a code walkthrough. Practise saying why out loud, not just typing.
  • The rubric at the end is the grader's checklist. Build toward it, then self-grade honestly.
"A working happy path is table stakes. I hire on the error state, the test seam, and whether you can tell me what you'd profile." β€” what a senior interviewer is actually thinking.

1The brief & the API contract

Pin the contract down before you write UI. A senior reads the API and immediately asks: what can go wrong?

Take a representative brief: show a searchable list of repositories from a JSON endpoint; tapping one shows detail. The endpoint returns:

json GET /search/repositories?q=swift
{
  "items": [
    { "id": 1, "name": "swift", "stargazers_count": 65000, "description": "The Swift language" },
    { "id": 2, "name": "SwiftUI-Intro", "stargazers_count": 320, "description": null }
  ]
}

The senior move is to enumerate the failure modes before the happy path:

  • Loading β€” the request is in flight; the user must see that, from the first frame.
  • Error β€” no network, a 500, a timeout, or JSON that doesn't decode. Each must be recoverable, not a crash.
  • Empty β€” a valid 200 with "items": []. This is a designed state, not an accidental blank list.
  • Loaded β€” the happy path. Note description is nullable β€” model that honestly.
⚠️ The tellCandidates who model only the loaded state and bolt the others on later read as mid-level. Seniors model the state space first β€” the UI then becomes a pure function of it.

2Architecture: one state enum, one seam

Two decisions carry the whole build: model state as a single value, and put a protocol between you and the network.

Model the domain (value types)

swift Decodable domain models β€” nullable fields modelled honestly
struct Repo: Identifiable, Decodable, Equatable {
    let id: Int
    let name: String
    let stars: Int
    let description: String?   // nullable in the API β€” keep it optional

    enum CodingKeys: String, CodingKey {
        case id, name, description
        case stars = "stargazers_count"
    }
}

struct RepoSearchResponse: Decodable { let items: [Repo] }

The state as a single source of truth

Don't scatter isLoading, error, and repos as independent properties β€” they let impossible states exist (loading and errored). Make state one enum:

swift impossible states become unrepresentable
enum LoadState<Value> {
    case loading
    case loaded(Value)
    case empty
    case failed(String)   // user-facing message
}

The seam that makes everything testable

The single most important line in a senior take-home: the view-model depends on a protocol, not on URLSession. That seam is what lets your tests run offline, instantly, deterministically.

swift protocol DI β€” the network is injected, never reached for
protocol RepoService {
    func search(_ query: String) async throws -> [Repo]
}

struct LiveRepoService: RepoService {
    let session: URLSession = .shared
    func search(_ query: String) async throws -> [Repo] {
        var c = URLComponents(string: "https://api.example.com/search/repositories")!
        c.queryItems = [URLQueryItem(name: "q", value: query)]
        let (data, response) = try await session.data(from: c.url!)
        guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode(RepoSearchResponse.self, from: data).items
    }
}

The view-model is @MainActor (UI state mutates on the main actor), holds the injected service, and maps results to the state enum β€” including the empty case:

swift @Observable view-model (iOS 17+); ObservableObject is equivalent pre-17
import Observation

@MainActor @Observable
final class RepoListModel {
    private(set) var state: LoadState<[Repo]> = .loading
    private let service: RepoService

    init(service: RepoService) {   // <- injected, not a singleton
        self.service = service
    }

    func search(_ query: String) async {
        state = .loading
        do {
            let repos = try await service.search(query)
            state = repos.isEmpty ? .empty : .loaded(repos)
        } catch is CancellationError {
            return                                  // a newer search superseded this one
        } catch {
            state = .failed("Couldn't load repositories. Check your connection and try again.")
        }
    }
}
πŸ’‘ Why this scoresThe view never sees URLSession, never sees a raw Error. It renders state. The model maps empty results to .empty explicitly β€” that one line is what separates a senior submission.

3Render all four states

The view is a switch over the state. Every branch is a real, designed screen β€” including the two everyone forgets.

swift the view is a pure function of state
struct RepoListView: View {
    @State private var model: RepoListModel
    @State private var query = "swift"

    init(service: RepoService) {
        _model = State(initialValue: RepoListModel(service: service))
    }

    var body: some View {
        Group {
            switch model.state {
            case .loading:
                ProgressView("Loading repositories…")        // never a frozen blank screen

            case .loaded(let repos):
                List(repos) { RepoRow(repo: $0) }

            case .empty:
                ContentUnavailableView("No repositories",
                    systemImage: "magnifyingglass",
                    description: Text("Nothing matched β€œ\(query)”. Try another search."))

            case .failed(let message):
                VStack(spacing: 12) {
                    ContentUnavailableView("Something went wrong",
                        systemImage: "wifi.slash", description: Text(message))
                    Button("Try again") { Task { await model.search(query) } }
                        .buttonStyle(.borderedProminent)
                }
            }
        }
        .task { await model.search(query) }                  // initial load
    }
}
🚩 Red flagAn error path that just print(error) and shows an empty list. To a grader that's indistinguishable from "the feature is broken." Always give the user a message and a way out (retry).
πŸ’‘ Senior touchContentUnavailableView (iOS 17+) is the platform-native empty/error affordance β€” reaching for it shows you keep current. On older targets, a centred VStack with an SF Symbol + copy + action does the same job.

4Test it offline against a fake

Because the service is a protocol, the test injects a fake and exercises every state in milliseconds β€” no network, no flake.

Before revealing the reference: which three tests would you write first? (Hint: one per non-happy state, because those are the ones that ship broken.)

swift a fake conforming to the same protocol
struct FakeRepoService: RepoService {
    var result: Result<[Repo], Error>
    func search(_ query: String) async throws -> [Repo] {
        try result.get()
    }
}
swift Swift Testing (Xcode 16+) β€” XCTest equivalents in comments
import Testing
@testable import RepoFeature

@MainActor
struct RepoListModelTests {

    @Test func loadedWhenServiceReturnsRepos() async {
        let repo = Repo(id: 1, name: "swift", stars: 10, description: nil)
        let model = RepoListModel(service: FakeRepoService(result: .success([repo])))
        await model.search("swift")
        guard case .loaded(let repos) = model.state else { Issue.record("expected .loaded"); return }
        #expect(repos == [repo])
    }

    @Test func emptyWhenServiceReturnsNoRepos() async {
        let model = RepoListModel(service: FakeRepoService(result: .success([])))
        await model.search("zzz")
        guard case .empty = model.state else { Issue.record("expected .empty"); return }
    }

    @Test func failedWhenServiceThrows() async {
        let model = RepoListModel(service: FakeRepoService(result: .failure(URLError(.notConnectedToInternet))))
        await model.search("swift")
        guard case .failed = model.state else { Issue.record("expected .failed"); return }
    }
}
πŸ’‘ What this provesYou tested the branching logic β€” the part that actually breaks β€” not the network library (Apple already tested that). Stating exactly that out loud is a strong-answer marker.

Mention what you'd add with more time: a decode test feeding raw JSON through JSONDecoder, and a snapshot test per state. Naming the next test you'd write shows judgement about where coverage is worth buying.

5Accessibility β€” by reflex, not on request

Seniors are spotted by whether accessibility is in the first draft. It's a few lines, and graders look for it.

swift a row that reads well under VoiceOver and Dynamic Type
struct RepoRow: View {
    let repo: Repo
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(repo.name).font(.headline)
            if let description = repo.description {
                Text(description).font(.subheadline).foregroundStyle(.secondary)
                    .lineLimit(2)                                  // truncates, never clips layout
            }
            Label("\(repo.stars)", systemImage: "star.fill").font(.caption)
        }
        // one clear spoken phrase instead of three disjointed reads:
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(repo.name), \(repo.stars) stars")
    }
}

The checklist graders run

  • VoiceOver β€” every actionable element has a meaningful label; decorative images are hidden.
  • Dynamic Type β€” use semantic fonts (.headline, not fixed sizes); test the largest size for clipping.
  • Contrast & targets β€” AA contrast; 44Γ—44pt minimum hit targets.
  • Don't rely on colour alone β€” pair the error colour with an icon and text.
πŸ’‘ Cheap, high-signalThis is ~6 lines and it visibly separates you. If you only have time for one "extra," do this one.

6The performance note

You won't profile a take-home under time β€” but a senior leaves a short, specific note on where it could hitch and what they'd measure. That note is the deliverable.

"Performance note: list scrolling is the risk area. Rows are cheap and Repo is Identifiable with a stable id, so SwiftUI diffs cleanly. If rows showed remote avatars I'd decode images off the main actor and add a memory+disk cache, since image decode on the main thread is the classic source of scroll hitches. I'd verify with the Time Profiler and an os_signpost around row appearance, targeting 60fps. The search re-fetches on each keystroke β€” I'd debounce ~300ms and cancel the in-flight task (the model already handles CancellationError) to avoid wasted work."

The reasoning a grader rewards

  • Name the risk area (scroll / launch / memory) rather than hand-waving "it's fast."
  • Tie cause to fix: main-thread image decode β†’ decode off-main + cache.
  • Say how you'd measure: Time Profiler, signposts, a concrete budget (60fps, launch < X ms).
  • Flag stable identity: Identifiable with a real id so the diffing engine isn't re-rendering everything.

7The grader's rubric β€” self-check

This is roughly what a senior reviewer ticks. Answer honestly; then the checklist below is your build target.

You've built the loaded list and it works. What's the highest-value thing to do next? priorities
The non-happy states are where take-homes are won or lost β€” they're what a grader checks first and what ships broken in real apps. Polish and generality come after the state space is complete.
Why inject RepoService as a protocol instead of calling URLSession in the view-model? testability
The seam is the whole point: with a fake conforming to the protocol you exercise loaded/empty/failed in milliseconds, no network, no flake. Testability is an architecture property, not a test count.
Modelling state as enum LoadState instead of separate isLoading/error/repos properties mainly buys you… state
A single enum makes contradictory combinations impossible to construct, and the view becomes an exhaustive switch β€” the compiler forces you to handle every state.
An interviewer asks "how would you make this faster?" with no profiler open. The strongest answer… performance
Senior performance reasoning is hypothesis + fix + measurement: name where it could hitch, the targeted fix, and the tool (Time Profiler, signposts) and budget you'd verify against β€” never "just optimise everything."
Which single addition gives the most hire-signal for the least effort? ROI
Accessibility is a handful of lines and most candidates skip it β€” so doing it by reflex is disproportionately strong signal that you ship production-quality work.

8You've mastered the take-home when you can…

Tick these honestly β€” they're the build target, and they save in your browser.

  • Decode the API into value-type models, modelling nullable fields as optional.
  • Model the screen as a single LoadState enum β€” loading / loaded / empty / failed.
  • Put a protocol seam in front of the network and inject it into the view-model.
  • Render all four states, each with recoverable, designed UI (error has a retry; empty has copy).
  • Write unit tests for loaded / empty / failed against a fake, running offline in milliseconds.
  • Add VoiceOver labels and Dynamic Type support without being asked.
  • Write a specific performance note: the risk area, the fix, and how you'd measure it.
  • Walk an interviewer through every decision and defend each trade-off calmly.
  • Do the whole thing in one timed sitting (Overlay Week 22 β€” the dry run).
πŸ”­ Where this fits

This is the practical app-build item on the readiness gate, the highest-leverage round in the Australia track, and your strongest counter to "senior but can't ship." Build it twice: once reading along here, once cold under a timer.