Data Layer

Repositories, remote data sources, AppResult, and UiState.

The data layer is how features talk to Supabase without knowing about Supabase. It has two testable seams (repository and remote data source), a no-throw result type at the boundary, and a three-state screen model. Learn this shape once and every feature reads the same way.

The repository pattern

Each feature talks to a *RepositoryProtocol, never to the network directly. Repositories own business rules (ordering, normalization, retry) and map errors; the actual Supabase calls live in a separate *RemoteDataSource. That's two injection seams:

ViewModel → *RepositoryProtocol → *RemoteDataSource → SupabaseClient
                   ↑                      ↑
            inject a fake repo     inject a fake data source
            (view-model tests)     (repository tests)

TodosRepository shows it well: add(text:userId:) trims + validates the title, fetches existing rows to compute the next sort_order, then delegates the insert to TodosRemoteDataSource. Tests swap a fake data source so no network runs.

Not every repository needs the data-source split. TodosRepository and ProfileRepository use it; UploadsRepository talks to Supabase Storage directly because there's no row-mapping logic to isolate.

AppResult and AppError

Repositories don't throw across their boundary — they return AppResult<T>:

enum AppResult<Value> {
    case success(Value)
    case failure(AppError)
}

AppError carries a typed code (authentication, rateLimited, network, validation, notFound, unknown), a user-facing message, and the original error. AppError.map(_:) translates raw errors into friendly copy — supabase-swift AuthError cases become messages like "The email or password is incorrect", and URLError becomes "You appear to be offline…". View models show message directly.

Some repositories (TodosRepository, ProfileRepository) use async throws on their own protocol and let the view model catch and map via AppError.map. AuthRepository maps internally and returns AppResult directly. Both converge on AppError — the difference is just where map runs.

UiState — screen state

View models expose screen state as a single enum instead of a soup of isLoading / error / items flags:

enum UiState<Value: Sendable>: Sendable {
    case idle          // pre-load shell
    case loading       // initial fetch or reload
    case data(Value)   // success — use .data([]) for an empty result
    case error(String) // the AppError.message to show
}

idle renders a clean first shell; an empty success is .data([]) so the empty-state view can distinguish "no items" from "not loaded yet". View models are @MainActor @Observable; views own them with @State.

Models

Models are plain Codable structs with snake_case CodingKeys mapping to the Postgres columns, and are Identifiable + Sendable:

  • Todoid, userId, title, completed, sortOrder, timestamps.
  • Profileid, and optional fullName / bio / phone / location / avatarURL + timestamps (matches the profiles table; fields are nullable).

ProfileRepository writes one field at a time via a dynamic-key upsert payload (only id, updated_at, and the changed column go to Postgres), so partial edits never clobber other fields.

Row-level security expectations

Every table is RLS-protected and scoped to the signed-in user (auth.uid() = user_id, or = id for profiles). Because RLS enforces ownership server-side, the app's queries simply filter by user_id and trust the policy. The subscriptions / payments tables are SELECT-only for clients — writes happen exclusively in service-role Edge Functions. See Supabase Setup for the full policy SQL.

Offline awareness

NetworkObserver (@MainActor @Observable) wraps NWPathMonitor and publishes isOnline, mounted app-wide to drive the offline banner (see UI Components). It's behind a NetworkMonitoring protocol so tests inject a fake path status. Repositories stay network-only today — there's no local cache — so the banner is how the UI signals degraded state.

Local preferences

AppPreferences is an @Observable wrapper over UserDefaults for non-secret app state (theme, language, onboarding/consent flags). Auth tokens never go here — they live in the Keychain (see Authentication).

Adding your own feature

Follow the Todos shape end to end:

Model

Add a Codable, Identifiable, Sendable struct with snake_case CodingKeys matching your table.

Remote data source

Define a *RemoteDataSource protocol + a Supabase*RemoteDataSource doing the from("table").select()/insert()/update()/delete() calls.

Repository

Add a *RepositoryProtocol + implementation holding business rules; add a convenience init(supabase:) that wires the real data source.

View model + view

A @MainActor @Observable view model exposing UiState<T>, mapping failures through AppError.map; a SwiftUI view owning it with @State.

Register in AppEnvironment

Construct the repository in the DI container so views resolve it — see Architecture.

On this page