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:

  1. VM unit tests with mocked dependencies. Fast, deterministic, run on every commit.
  2. Snapshot tests for the highest-traffic screens. Catches accidental visual regressions.
  3. 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

What is non-negotiable

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.