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.
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.
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:
{
"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
descriptionis nullable β model that honestly.
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)
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:
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.
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:
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.")
}
}
}
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.
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
}
}
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).ContentUnavailableView (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.)
struct FakeRepoService: RepoService {
var result: Result<[Repo], Error>
func search(_ query: String) async throws -> [Repo] {
try result.get()
}
}
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 }
}
}
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.
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.
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.
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:
Identifiablewith 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.
RepoService as a protocol instead of calling URLSession in the view-model? testabilityenum LoadState instead of separate isLoading/error/repos properties mainly buys youβ¦ stateswitch β the compiler forces you to handle every state.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
LoadStateenum β 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).
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.