Architecture

MVVM, the repository pattern, AppEnvironment DI, and the navigation model.

The template is MVVM + Repository with hand-rolled dependency injection, built entirely on the iOS 17 Observation framework and Swift Concurrency. There is no Combine, no ObservableObject, and no third-party DI container. This page covers the four patterns you'll touch in every feature: DI, view models, navigation, and concurrency — plus the testing conventions that keep them honest.

Dependency injection via AppEnvironment

One @MainActor @Observable final class constructs every controller and repository in its init, and SwiftUI carries it down the view tree.

@MainActor @Observable
final class AppEnvironment {
    let session: SessionController
    let theme: ThemeController
    let todosRepository: any TodosRepositoryProtocol
    // …one property per app-wide service

    init(todosRepository: (any TodosRepositoryProtocol)? = nil, /* … */) {
        self.session = SessionController(authRepository: authRepository)
        self.todosRepository = todosRepository ?? TodosRepository(supabase: supabase)
        // …
    }
}

SoarStarterSwiftApp builds one instance and injects it (plus a handful of controllers that need their own @Environment identity):

RootView()
    .environment(env)
    .environment(env.session)
    .environment(env.theme)
    // …

Views resolve dependencies by type:

@Environment(AppEnvironment.self) private var env

The init parameters default to nil and fall back to the real implementation — so a test (or a SwiftUI #Preview) can pass a fake repository while production passes nothing. No Swinject, no Factory.

@Observable types cannot be @EnvironmentObject. That API is for the old ObservableObject protocol. Always inject with .environment(x) and read with @Environment(X.self). Mixing the two silently fails to update the view.

MVVM with @Observable view models

Every feature with logic pairs a view with a view model held in @State:

struct TodosView: View {
    @State private var viewModel: TodosViewModel
    // …
}

@MainActor @Observable
final class TodosViewModel {
    var state: UiState<[Todo]> = .idle
    // …
}

Screen state is a UiState<T> enum rather than a soup of booleans:

enum UiState<Value: Sendable>: Sendable {
    case idle       // pre-load shell
    case loading    // initial fetch or reload
    case data(Value) // .data([]) is a valid empty success
    case error(String)
}

The view switches over state to pick LoadingState, ErrorState, EmptyState, or the content — see UI Components. View models are @MainActor, so mutating state from an async method is safe without manual hops back to the main thread.

The repository boundary

View models never call Supabase directly. They depend on a *RepositoryProtocol; the repository owns retry policy and error mapping and sits in front of a *RemoteDataSource that wraps the actual network call:

View → ViewModel → RepositoryProtocol → Repository → RemoteDataSource → Supabase

Two seams for fakes: view-model tests inject a fake repository; repository tests inject a fake remote data source. Repositories surface failures as AppResult<T> / AppError (the Auth and Subscription repositories) or throw a mapped AppError (Todos, Profile) — either way the view model translates the outcome into a UiState. Full walkthrough in Data Layer.

Two distinct mechanisms, deliberately separated:

Gates decide whether you reach the app. RootView nests them outside-in; each reads persisted state and either shows its child or interrupts. This replaced a top-level route enum entirely. See App Gates.

Stacks decide where you are inside the app. MainShellView holds a TabView of four tabs, and each tab owns its own @State navigation path:

@State private var homePath: [HomeRoute] = []
@State private var componentPath: [ComponentRoute] = []
@State private var mePath: [MeRoute] = []

Each feeds its own NavigationStack(path:). Because the paths are distinct @State, per-tab back stacks survive tab switches (switch away mid-drill-down, come back, you're still there). Route destinations are Hashable enums in Navigation/MainRoute.swift; the auth flow has its own private route enum inside App/AuthGate.swift.

This is why deep links resolve to a (tab, path) pair — see Deep Links. MainShellView drains the pending link by setting selectedTab and replacing that tab's path.

Concurrency: MainActor by default

The project builds with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor — every type is @MainActor-isolated unless you say otherwise. This kills most data-race warnings for UI code (view models, controllers) with zero annotation.

The trade-off: background work must be opted out explicitly. Reach for nonisolated on functions that must run off the main actor, and Task.detached for genuinely concurrent work. Models crossing actor boundaries are Sendable (the Codable structs already are). async/await is used throughout; there is no Combine.

Testing conventions

TargetFrameworkNotes
SoarStarterSwiftTestsSwift Testing (@Test, #expect)Unit tests. Inject fakes at the repository or remote seam.
SoarStarterSwiftUITestsXCUITestOne smoke test: launches with -UITestingResetState, taps through onboarding + consent, asserts the shell renders.

Both targets use PBXFileSystemSynchronizedRootGroup: any *.swift file dropped into the folder joins the target automatically — no project.pbxproj edit. Pure-Swift seams (AuthValidation, ReferralLinkProvider, the catalogs) are unit-tested directly.

Always run tests with -parallel-testing-enabled NO. By default xcodebuild clones the simulator and runs the unit target and the UI smoke target concurrently; the CPU-heavy unit suites starve the smoke test's first waitForExistence, which then flakes. Serial execution is the supported mode:

xcodebuild -project SoarStarterSwift.xcodeproj -scheme SoarStarterSwift \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
  -parallel-testing-enabled NO test

On this page