Multi-service iOS app patterns
Reusable architecture and agent-workflow patterns for device-to-many HTTP API apps (Keychain, FeatureState, phased integrations, inspection routes).
Patterns for device → many independent HTTP APIs apps (dashboards, service clients, and multi-service workflows). Keep examples neutral and reusable: no branded or environment-specific references, and no secrets.
Designed for dashboards, service clients, and independent service peers. Apply in
the active application workspace, not this foundation’s archive/.
When to use
- App talks to 2+ services, each with its own base URL and credential.
- Prefer direct device→service connections for the first release.
- Secrets must stay off disk-in-UserDefaults and out of fixtures/logs.
- Agents will implement integrations and UI; teams set phase boundaries.
Phase slice
Ship capability in read-only slices before mutations:
| Phase | Goal | Avoid |
|---|---|---|
| Foundation | Registry, Keychain refs, transport, probe, one list surface | Writes, search, delete |
| Enrichment | Detail, related resources, remote assets, status aggregation | Display-name “joins” across services |
| Transfer client | Separate client for transfer UI | Using the wrong service for resource availability |
| Mutations | Pause/resume, monitor toggles, destructive ops | Shipping controls before read state is trustworthy |
Rule: resource-associated “in progress” state often belongs to the orchestrator API, not the transfer client. Full transfer lists belong to the transfer client. Document the join key early (stable id / hash) and ban display-name matching.
Architecture sketch
AppDependencies (@Observable / environment)
├─ ServiceRegistry (non-secret config → Preferences)
├─ KeychainStore (secrets by CredentialReference)
├─ HTTPTransport (mockable; scheme policy)
├─ ConnectionResolver (local/remote preference + probe)
└─ Repository (aggregate FeatureState per feature)
ServiceClientFactory → per-kind actor clients (DTO in, domain out)
Domain boundary
- Config — kind, display name, endpoints (scheme/host/port/path), preference, opaque credential reference.
- Clients — private DTOs, auth headers/cookies, path construction including optional
urlBase/ base path. - Domain models —
Resource,CollectionItem,TransferItem,ServiceStatus, … only types UI should see. - Repository — fan-out enabled services, merge by stable ids, map errors to recovery.
Do not leak raw JSON or service field names into SwiftUI views.
Secrets and credentials
| Store | What |
|---|---|
| Preferences / registry | Non-secret config, credential reference (UUID account id) |
| Keychain | API keys, tokens, username/password |
| Memory only | Session credentials (re-authenticate on expiry) |
| Never | Secrets in fixtures, launch args, screenshots, logs, sample data |
Connection Test: probe with an ephemeral Keychain account so Test never overwrites the live secret on edit. Empty credential field on Save keeps the existing Keychain value.
Auth mapping: HTTP 401/403 → unauthorized; missing secret → missing credential; transport failure → unavailable. UI recovery: Retry vs Open Settings / re-enter credentials.
Never log tokens, passwords, session credentials, or endpoint URLs containing sensitive data.
Credential lifecycle
- Rotate credentials by writing the replacement only after it has been validated; preserve the prior value until the replacement succeeds.
- Deleting a credential or removing an account must remove its Keychain item, clear in-memory sessions, cancel in-flight work, and purge account-scoped caches and persisted endpoint configuration.
- Never place credentials in query strings, logs, screenshots, fixtures, sample data, or handoff text. Use request headers or secure session storage instead.
Transport policy
- Prefer HTTPS for remote endpoints.
- Allow user-explicit plain HTTP only where a documented local-network requirement exists (document which endpoint labels allow it).
- ATS: narrow exception (
NSAllowsLocalNetworkingor equivalent)—notNSAllowsArbitraryLoads. - Never disable certificate validation to “make it work.”
- Normalize endpoints with URL components before storing or requesting them: trim input, reject malformed URLs, user-info credentials, fragments, and unsupported schemes, and enforce the configured scheme allowlist before and after redirects.
- Timeouts, cancellation, retry/backoff, and a mockable
HTTPTransportprotocol are mandatory for tests. Document timeouts by request class; cancel work when its feature leaves scope; retry only idempotent transient failures with capped exponential backoff and jitter, never authentication or validation failures.
FeatureState (feature-level loading)
Use a small enum for every remote feature surface:
loading | content(value, refreshedAt) | empty | stale(value) | unavailable(recovery) | unauthorized | disabled
- Prefer one status row per service (probe OK + content fail must not double-emit connected + failed).
- Partial multi-service success: show connected peers + failed peers without blanking the whole app when any sibling works.
- Recovery actions are explicit: retry, update credentials, configure endpoint, enable service.
- Show stale values with their refresh time and recovery action. Define cache expiration per resource, avoid treating expired cache as fresh content, and purge account-scoped cache on credential deletion or account removal.
Remote assets
- Prefer absolute remote URLs when the API provides them.
- Relative asset paths need a normalized base URL and header-based authorization.
- Never log authenticated asset URLs.
- List rows use fixed compact frames + clip; do not let variable-aspect-ratio assets overflow adjacent cards.
- Cache bytes when scroll causes probe-per-image cost.
Cross-service joins
| Do | Don’t |
|---|---|
| Join on service ids / hashes (case-insensitive when required) | Match by display name |
| Keep private join fields off ordinary UI | Surface raw hashes as primary labels |
| Document which service owns each status or badge | Require one service’s credentials to show another service’s private state |
System UI first: Liquid Glass
Liquid Glass is a navigation/control treatment, not an app-wide background style. This rule applies to every SwiftUI app; it is especially important in a multi-service app, where dense lists, status cards, and partial-failure states must remain readable.
- Use system chrome first. Prefer native
TabView, navigation bars, toolbars, and sheets. On supported OS versions, let the system provide the current Liquid Glass appearance, including its material, shape, selection, safe-area behavior, and interaction. - Keep the content plane opaque. Lists, tables, cards, forms, and feature surfaces should remain solid/opaque. Do not apply glass to content merely because the app uses Liquid Glass in its navigation chrome.
- Add custom glass only for a real navigation-plane gap. A floating action
or control may use
glassEffectonly when a native container cannot express it; useGlassEffectContainerfor coordinated glass elements. Never rebuild a tab bar or navigation bar with materials, overlays, or a custom safe-area bar. - Preserve the platform contract. Keep controls around ~44×44 pt, use system text styles, support Dynamic Type and VoiceOver, maintain contrast and Differentiate Without Color, and respect Reduce Motion. Keep the native fallback on older supported OS versions instead of imitating a newer glass appearance.
The short version: when a future app request says “use Liquid Glass,” start by adopting system navigation chrome. Reach for custom glass only after confirming that system UI cannot express the needed navigation-plane control.
Agent handoff shape
Every multi-step integration handoff should include:
- Do not redo — already-fixed items (regression surface only).
- Severity / priority bands — P0 trust → P1 next client → P2 auth → P3 peers.
- File map — exact paths.
- API truth doc — prefer a fact-checked agent guide over marketing copy or unstable public specs for endpoints.
- Non-goals — no push, no secrets in fixtures, no write APIs this phase.
- Paste prompt — zero-context instruction for a fresh agent.
- Verification gate — scheme,
test_simparallel off, inspection screenshots when UI changes.
Inspection contract (deterministic UI)
For agent-driven UI verification:
- Launch arguments such as
--inspection <route>for loading / empty / unauthorized / primary tabs. - Optional
--light-appearance, accessibility Dynamic Type flags. - For each touched user-visible route: screenshot → read the image →
snapshot_uihierarchy check. - Keep inspection routes covered by unit tests when adding critical surfaces.
Testing
| Layer | Practice |
|---|---|
| Transport | Stub / path-routing mock; assert scheme policy and status mapping |
| Clients | Decode synthetic fixtures only (fixture.invalid, fake ids) |
| Repository | Multi-service partial failure and status dedupe |
| Secrets skim | Grep fixtures for credentials, tokens, and passwords before commit |
XcodeBuildMCP: session_show_defaults once per session; test_sim with parallel testing disabled for scheme stability; build_run_sim for smoke when UI changes.
New service client checklist
ServiceKindcase + registry form fields (correct credential shape).- Actor client conforming to shared
ServiceClient(probe). - Private request helper: Keychain load + auth headers/cookies + base path.
- DTO → domain mapping.
- Register in factory.
- Fixtures + decode/auth tests.
- Icon asset (template or app-provided mark per application policy).
- No live network in CI.
Distribution and privacy note
If this pattern ships publicly, document endpoint security, local-network usage, and data handling in the application and privacy plan. Keep application-specific policy out of fixtures.
Related foundation pages
- Session workflow — full agent loop
- Task recipes — scenario shortcuts
- AGENTS.md template — project-local always-on rules
- XcodeBuildMCP — verification ladder
- Security defaults — never log secrets; see always-on
AGENTS.mdin this repo
Anti-patterns
- Global TLS disable or arbitrary ATS loads for one service
- Storing API keys next to base URLs in UserDefaults
- Sample records that look live after real service data exists
- Display-name matching between services
- Re-researching service APIs when a fact-checked agent guide exists
- Expanding this foundation’s
archive/Sourcesfor app work - One monocommit of P0–P3 without intermediate green tests
Keep this page application-neutral. Application-specific severity lists and
paste prompts live in the active workspace’s Documentation/.