Reeler: rubric and defend your code
Unit 24 · Project 4, rubric. Score yourself honestly against each row, then answer the defend-your-code prompts as if a senior engineer were reviewing your PR. If you can't defend a decision, that's the part to revisit before moving to Project 5.
A finished project isn't "it runs." It's "it runs, and I can explain why every non-obvious choice
is the right one." This lesson is the checklist and the viva. Run /ship-check first to confirm
the mechanical gates, then work the rubric.
Rubric
Score each row: 2 solid, 1 partial, 0 missing. Aim for 18+ of 22.
| # | Criterion | What "solid" looks like |
|---|---|---|
| 1 | Injected client | SearchViewModel takes a FilmAPIClient protocol; a stub is used in tests/previews |
| 2 | View model isolation | View model is @Observable @MainActor; state is a single enum, not loose flags |
| 3 | Debounced search | Keystrokes don't each fire a request; latest keystroke wins; cancellation swallowed |
| 4 | Value-based navigation | NavigationLink(value:) + navigationDestination(for:); Film is Hashable |
| 5 | Pagination correctness | Load-more with an in-flight guard; no duplicate rows, no runaway page counter |
| 6 | Actor image cache | Cache is an actor; concurrent loads are safe; downloads are coalesced |
| 7 | No retain cycles | Stored closures that reference their owner capture self weakly |
| 8 | SwiftData film data | @Model FilmActivity with a unique key; rating/like/watchlist/review persist; works offline |
| 9 | Views as filters | Diary / Watchlist / Profile are @Query filters over one FilmActivity store, not separate stores |
| 10 | Bindable editing | Rating, like, watchlist, and review write straight to the model via @Bindable — no manual save |
| 11 | Tokens + design contract | No raw hex, no gradients/glow; SF Symbols only; light and dark both authored |
| 12 | Reviewed AI diffs | You ran /swift-review on non-trivial AI-proposed code and rejected what failed |
Defend your code
Answer each aloud or in writing. The goal is a crisp, correct sentence — the kind you'd give in a real review.
Why is SearchViewModel marked @MainActor?
Because it holds UI state SwiftUI reads on the main thread, and it mutates that state after
awaiting the network. @MainActor guarantees those mutations happen on the main actor with no
manual thread-hopping, and makes the read-modify-write on isLoadingPage atomic with respect to
the UI. Without it, you'd have to hop to the main actor by hand on every assignment and risk a data
race on the paging flags.
Why is the image cache an actor but the view model a @MainActor class?
Different concurrency needs. The cache is touched by many background image loads at once, so it
needs its own isolation domain that serializes concurrent access — an actor. The view model is
touched by the UI, which is inherently main-actor, so it belongs on the main actor. Putting the
cache on the main actor would drag every download's bookkeeping onto the main thread; putting the
view model on a custom actor would force an await on every UI read.
Why is Film a struct, and why does that matter for concurrency?
Value semantics: a Film handed from the API client (a background context) to the main-actor view
model is a copy nobody else can mutate, so it's Sendable for free and crosses the actor boundary
safely. A class would have to be made Sendable deliberately (and justified).
Where did you break a retain cycle, and how did you know it was one?
Anywhere an object stored a closure that referred back to that same object — the loader's stored
onReload. You know it's a cycle when the object holds the closure (a stored property) and the
closure holds the object (a strong self capture). The fix is [weak self] plus
guard let self.
Carry-forward
Reeler consumed someone else's API. Project 5 (Snapgram) gives you a backend of your own —
Supabase auth, a Postgres database with Row-Level Security, and storage for photo uploads. The
architecture you just built (injected client, @MainActor view model, actor for shared caches,
SwiftData for local truth) carries directly; you'll swap the read-only film client for a Supabase
client you authenticate against. The Pair-mode review habit carries too — Project 5's AI checkpoint
seeds a nastier flaw: a data race that only bites under concurrent load.
Knowledge check
Q: Your Diary shows nothing in airplane mode. Is the app broken?
It should not be — your film data is SwiftData, read locally by @Query, with no network
dependency. If it vanishes offline, you're re-fetching from the network instead of reading the
local store, which defeats the offline goal. The local FilmActivity store is the source of truth
for everything you've logged.
Q: Why is the Diary not its own @Model, separate from the Watchlist?
Because they're the same data seen two ways — logged films vs. watchlisted films. One
FilmActivity store with @Query filters keeps a single source of truth, so a rating set on the
detail page shows up in the Diary and the Profile average with nothing to synchronize.
Q: What is the single most important thing to carry from Reeler into the backend projects?
Dependency injection behind a protocol. It's what let you test the view model with a stub instead
of the network, and it's exactly the seam where a FilmAPIClient becomes a SupabaseClient in the
next project without rewriting the view model.