Every iOS engineer's blog has a SwiftUI architecture post. Most are theoretical. This one is empirical: the exact pattern set we use across eight shipping Tappa apps, the ones we have stopped using, and the reasons for each.
The default — Observation + small VMs
For every new app since 2024, we use Apple's @Observable macro and small per-screen view models. No global store, no Redux-shaped abstraction.
@Observable
final class CounterModel {
private(set) var count = 0
func tick() { count += 1 }
}
struct CounterView: View {
@State var model = CounterModel()
var body: some View { ... }
}
The per-screen VM owns the state for its screen and the ones below it. We do not lift state to the root unless we have to.
Navigation — NavigationStack with a path enum
A single typed NavigationPath per flow, populated via an enum:
enum Route: Hashable { case detail(Item.ID), settings }
@State private var path: [Route] = []
This is unglamorous and survives every iOS update. We tried fancier router abstractions in 2023; we removed them in 2024.
Async work — structured concurrency, not Combine
For new code, we use async/await with .task { ... } modifiers. Combine still appears in older modules. We do not refactor for refactoring's sake, but no new Combine pipelines get added.
Dependency injection — initializer injection, period
No property wrappers, no service locators, no environment singletons for business logic. Each VM takes its dependencies in init. Testing is trivial. Reasoning is trivial. Senior engineers reading the code can predict its behavior in seconds.
We make exceptions for genuine cross-cutting concerns: analytics, logging, the system clock for time-dependent tests.
Persistence — SwiftData where simple, GRDB where serious
SwiftData for light personal data (user preferences, journal entries). GRDB for anything where we want explicit SQL, predictable migrations, or large datasets. The line is roughly: under 1,000 rows or under 10 columns, SwiftData is fine. Above that, GRDB is worth the learning curve.
Testing — the parts we actually invest in
We do not chase coverage. We invest in three layers:
- VM unit tests with mocked dependencies. Fast, deterministic, run on every commit.
- Snapshot tests for the highest-traffic screens. Catches accidental visual regressions.
- A small UI test suite for the critical purchase flow. If a paywall breaks, we want to know in CI.
What we have stopped doing
- TCA (The Composable Architecture) — used it on one app in 2023, removed it in 2024. It is a beautifully designed library; for a small studio with multiple apps, the cost-per-feature was higher than initializer injection.
- MVVM-C with a separate Coordinator layer. Replaced with the typed-path navigation above.
- GraphQL clients for personal-data apps. Overkill. REST + Codable is fine.
What is non-negotiable
- Strict concurrency on for every target.
- Linting (SwiftLint) gating CI.
- A
Tasks.mdchecklist at the top of every PR.
Is this the right architecture for you? Maybe. The honest answer is: pick the smallest pattern set you can defend, write it down, and protect it from drift. Discipline beats elegance every time.