An iPhone store app built as an architecture vehicle: a realistic catalog → detail → cart flow used to exercise a layered SwiftUI/SwiftData stack — a generic repository layer, a three-tier image cache, and a heterogeneous product model — without the noise of a real commerce backend (no auth, no payments, no orders).
Written in Swift 5/SwiftUI with no third-party dependencies. Everything below is first-party Apple frameworks.
The store app is the payload; the repository is the experiment. It was built to exercise three things at once:
1. A unit-testing practice. Tests are written with Swift Testing — @Test / #expect,
suites as plain structs — rather than XCTest, against an in-memory SwiftData store so no test
ever touches disk. The architecture is shaped by that requirement: the repository layer takes an
injected stack precisely so a test can hand it a memory-only one.
2. A target for Xcode Cloud. The project is kept deliberately plain for Apple's hosted CI —
no SPM packages, no CocoaPods, no generated files, no scripted build steps. xcodebuild on a
clean clone is the whole build, which is the property that makes a project cheap to run on
managed CI.
3. Spec-driven development (SDD). Nothing here was written by opening a file and improvising.
Each feature starts as a versioned spec in specs/ — one file per domain, decisions settled
before code — which is then broken into a task backlog and implemented against that spec. The
specs/ documents in this repository are the actual inputs the store app was built from, not
documentation written afterwards.
The SDD workflow is driven by akios, a public
open-source plugin for spec-driven development with coding agents. It supplies the pipeline —
idea → spec → task backlog → implementation → review — while AGENTS.md, Context.md and
CLAUDE.md at the repo root are this project's own operating manual: stack, commands,
conventions, and which gate applies to which kind of task.
| Language / UI | Swift · SwiftUI (@Observable, NavigationStack) |
| Persistence | SwiftData (@Model, @Query, ModelContainer) |
| Concurrency | async/await, actor, @MainActor isolation |
| Tests | Swift Testing (@Test / #expect) for units · XCTest for UI |
| Target | iOS 17.6+, iPhone only (TARGETED_DEVICE_FAMILY = 1) |
| Dependencies | none (no SPM/CocoaPods) |
The project path and the scheme both contain a space — quote them.
open "Testing App/Testing App.xcodeproj"Build:
xcodebuild build -project "Testing App/Testing App.xcodeproj" -scheme "Testing App" -destination 'generic/platform=iOS Simulator'Test:
xcodebuild test -project "Testing App/Testing App.xcodeproj" -scheme "Testing App" -destination 'platform=iOS Simulator,name=iPhone 16'No install step, no code generation, no lint config — clone and run.
- Catalog — two-column masonry grid over five product categories, with category chip filtering, a sort sheet (popularity / recency / price ↑↓) and fuzzy search.
- Search — two-stage matching: exact substring first, then word-level Levenshtein with a
length-scaled edit-distance threshold, so
ceramikstill finds Ceramic. Recent searches persist inUserDefaults. - Product detail — hero image with a zoomable full-screen viewer, per-category attribute section, add-to-cart.
- Cart — sheet-presented, quantity editing, live total, empty state.
- Product status —
new/onSalebadges with asalePricethat driveseffectivePriceeverywhere (sorting, cart totals, price display). - Engagement — a
ProductEngagementrecord per product feeds the popularity sort.
Three layers, enforced by folder and by dependency direction — the presentation layer never
touches ModelContext directly.
Presentation Layer/ SwiftUI views, @Observable view models, routing
↓
Domain Layer/ @Model product types + the ProductDisplayable protocol
↓
Data Layer/ DIContainer · repositories · PersistenceStack · image cache
Paintings, sculptures, ceramics, jewelry and clothing have genuinely different attribute
shapes, so they are five separate @Model types unified by ProductDisplayable:
protocol ProductDisplayable: AnyObject {
var id: String { get set }
var price: Decimal { get set }
var salePrice: Decimal? { get set }
var status: ProductStatus { get set }
// …
func displayAttributes() -> [(label: String, value: String)]
}Views consume [any ProductDisplayable] and render displayAttributes() generically, so
adding a sixth category means adding a model — not touching the catalog UI.
One deliberate subtlety, documented at the declaration: Identifiable is not listed as a
requirement. PersistentModel already provides it non-isolated; re-declaring it on a
@MainActor protocol creates an isolated duplicate that breaks conformance.
SwifDataRepository is a @MainActor protocol with an associated Model: PersistentModel
and a default CRUD implementation (Repository+CRUD.swift), so each concrete repository —
cart, engagement, and one per product type — is a few lines of type declaration plus whatever
domain queries it actually needs.
The stack is valid or it does not exist. PersistenceStack exposes container and
context as non-optional, because its initialiser throws when SwiftData cannot open the
store. Holding a stack is therefore proof that the store is open: no call site branches on a
nil context, no write can silently degrade into a no-op, and the app never force-unwraps a
container. In-memory versus on-disk is chosen by which initialiser you call rather than by a
boolean flag, so a production call site cannot accidentally end up with a throwaway store.
Failure has one owner. DataStoreService is the only place that opens a store: it resolves
the store URL, runs the schema-version guard, recovers from a store that will not open (wipe the
file and its -shm/-wal companions, then retry once), seeds an empty store, and hands the
resulting stack to a DIContainer. Everything that can fail lives in that one readable sequence.
Its inMemory() and inMemorySeeded() doors build the same graph over a memory-only store —
which is what tests and previews use, so nothing under test ever touches the on-disk store.
DIContainer is then a passive composition root with a non-throwing initialiser: it takes an
already-open stack, hands the same stack to every repository, and exposes the same
ModelContainer to .modelContainer() — so @Query-driven views and repository writes observe
one context rather than diverging.
The launch outcome is kept as a Result at the app entry point. A device that genuinely cannot
open a store — full disk, restricted container — gets a screen with the error and a retry button
instead of a crash report.
@State private var store = Result { try DataStoreService.load() }ImageCaching is an actor singleton with a three-tier fetch strategy:
L1 NSCache (~100 MB, in memory)
↓ miss
L2 SwiftData store (separate container, "image-cache")
↓ miss
L3 URLSession
Concurrent requests for the same URL are deduplicated through an activeTasks: [URL: Task]
map, so a URL is never downloaded twice in parallel. Actor isolation makes that map safe
without locks; the SwiftData hops are @MainActor because the persistence layer is.
Card layout takes the matching care: an image that is still loading reserves space with a
shimmer at an estimated ratio, and a loaded image is .scaledToFit() with no enclosing
fixed ratio — so real photos keep their true proportions instead of being cropped to a uniform
grid cell.
22 @Test cases across nine Swift Testing suites, covering the parts where a regression
would be silent:
displayAttributes()for each of the five product types- repository CRUD round-trips (
PaintingRepositoryTests) against an in-memory stack - cart behaviour — add-or-increment, quantity increment, total price, product resolution
ProductStatus/effectivePrice/isOnSaleinvariants- engagement counters — view/cart-add increments and the derived popularity score
UI tests stay in XCTest (Testing AppUITests); unit tests stay in Swift Testing. The two are
never mixed.
Testing App/Testing App/
├── Presentation Layer/
│ ├── Routing/ App entry, ContentView, cart toolbar
│ ├── Catalog/ Grid, chips, sort & filter sheets, search, product card
│ ├── Product Detail/ Hero image, attributes, image viewer, add-to-cart
│ ├── Cart/ CartView + CartViewModel + row/empty state
│ └── Components/ Shared (PriceView)
├── Domain Layer/ 5 @Model types, ProductDisplayable, engagement, sort options
├── Data Layer/
│ ├── DataStoreService Store lifecycle: open, recover, seed
│ ├── DIContainer Composition root (repositories)
│ ├── Swift Data Repository/ PersistenceStack, protocol, generic CRUD
│ ├── Model Repositories/ One per model type + cart + engagement
│ └── Image Caching/ actor ImageCaching, ImageModel, ImageRepository
└── Assets/ SeedData
Stated plainly, since they are choices rather than oversights:
- Seed data only — products come from
SeedData.swift; there is no backend, soimageAspectRatiois wired through the model layer but leftnileverywhere. - No ViewModel for the catalog — filtering and sorting live in
CatalogViewcomputed properties.
The app is entirely under Testing App/. Everything else at the git root belongs to the SDD
workflow described above: specs/ holds the versioned specs each feature was built from,
tasks/ the backlog folders work moves through, and AGENTS.md / Context.md / CLAUDE.md
the operating manual. Memory.md is the decision log — including the approaches that were
tried, rejected, and why, so a later reader does not relitigate them.