Mobile architecture
Owner: Remy (Principal Mobile Engineer) · Status: locked (Kit + Dex acked) · Audience: Kit (iOS), Dex (Android), Reed (auth/API), Blake (ops)
This is the locked twin-app code architecture for Clubhouse League MVP (Kit + Dex acked). Treat it as the default shape for disbursed work and scaffolding PRs. Product locks stay in Product (Auth soft-gate, MVP scope, feature pages) — this page does not invent them.
Native twins only: iOS Swift/SwiftUI (Kit) and Android Kotlin/Compose (Dex). See Twin apps & disbursement.
Principles
- Contracts and shapes, not UI code — twins share API contracts, domain naming, and journey boundaries. No KMP, React Native, or Flutter. No shared UI kit in MVP.
- Product journeys drive modules — Auth, Leagues, Draft, Roster, Matchups, Scoring, Waivers map 1:1 to feature modules. Do not invent parallel nav taxonomies.
- Soft-gate — browse and onboarding work signed-out; auth before gated writes (create / request-join / redeem, then draft/roster/waiver mutations per MVP). Product detail: Auth soft-gate and Features → Auth.
- Server is identity authority — WorkOS JWT verified by Reed’s API (JWKS). Clients never treat email or a client-supplied user id as the identity key.
Recommended layers (both platforms)
Keep three layers. Presentation stays thin; domain stays pure; data owns I/O.
| Layer | Responsibility | Owns | Does not own |
|---|---|---|---|
| UI | Screens, navigation, presentation state | SwiftUI / Compose, nav graphs, view models or @Observable / ViewModel UI state | Networking, token persistence, JWT parse as source of truth |
| Domain | Use cases + pure models | Naming parity across Kit/Dex (same nouns: League, Roster, Matchup, …), soft-gate pending-action types | Platform UI, HTTP, Keychain/prefs |
| Data | Repositories, API client, local session/cache | URLSession / OkHttp (or thin wrapper), session store, repository fakes for tests | Screen layout, product copy |
Call direction: UI → Domain → Data. Domain does not import UI. Data does not import UI.
Recommended stack defaults
Thin and standard. Avoid heavy frameworks unless Kit or Dex pushes back hard with a concrete win.
iOS (Kit)
| Concern | Default |
|---|---|
| UI | SwiftUI |
| Navigation | NavigationStack |
| Concurrency | async / await |
| Networking | URLSession (or a thin typed wrapper) |
| Tokens | Keychain |
| UI state | Observation (@Observable) or equivalent — keep it first-party |
Android (Dex)
| Concern | Default |
|---|---|
| UI | Jetpack Compose |
| Navigation | Navigation Compose |
| Concurrency | Coroutines + Flow |
| Networking | OkHttp + Retrofit or Ktor client (pick one; document in the Android repo README) |
| Tokens | EncryptedSharedPreferences or DataStore (encrypted) |
| UI state | ViewModel + UI state holders |
Auth client (shared design)
Implement the same client behavior on both twins. Values and schemes are already locked in Delivery / Ops — do not re-decide them here.
- Staging vs prod — staging Client ID
client_01M2VWKF87Q557H9D37BFKC52H+clubhouseleague-staging://schemes; prod Client ID exists but prod WorkOS mutations stay locked until billing. Full table: Auth delivery. - Flow — system browser sheet + PKCE; callback and logout/returnTo schemes per build flavor. Package
com.pocketlabs.clubhouseleague. - Session store — persist tokens securely (Keychain / encrypted prefs). Treat Reed’s API as authority: on
401, clear local session and keep browse/onboarding available (soft-gate). - Pending action — preserve create / request-join / redeem (and later gated mutations) across the auth sheet; resume only after required profile (
workos_user_id,email,display_name). - Links — Product: Features → Auth; Delivery: Auth delivery; Ops: Auth deep-link hosts (OD-9 remaining: Universal Links / assetlinks).
Do not push sign-in background assets or Mira’s design dumps into the app repos from this workstream.
Code architecture
This section is what Kit and Dex implement against for empty-starter scaffolding and ongoing folder/module layout. Locking this page locks the code layout for later scaffolding PRs in clubhouse-league-ios and clubhouse-league-android. This PR still does not wire feature UI or push sign-in assets into those repos.
Both trees enforce UI → Domain → Data (see Recommended layers). Product journeys map 1:1 to feature folders/modules: Auth, Leagues, Draft, Roster, Matchups, Scoring, Waivers.
Dependency rules (both platforms)
- Feature UI may depend on that feature’s Domain and on AppCore /
:core:*. - Feature Data implements Domain ports (repositories / interfaces); Domain does not import Data or UI.
- Features do not import sibling feature UI. Cross-feature coordination goes through AppCore /
:core:*, navigation hosts, or shared domain models — not by reaching into another feature’s screens. - Auth is a feature module invoked as a gate, not a tab root — opened from onboarding, browse, create, join, redeem (see Feature Auth architecture).
- Shared models that cross features live in AppCore (iOS) or
:core:model(Android) — do not duplicate League / Roster / Matchup types inside each feature.
iOS (clubhouse-league-ios)
Propose the source tree under ClubhouseLeague/ (or Sources/ if Kit prefers a Sources-root layout). Single target is OK for MVP — no SPM package split required yet — but folders must enforce UI → Domain → Data. In Xcode, make each group a real folder on disk (group = folder).
ClubhouseLeague/
App/ # @main, App root, env bootstrap
AppCore/
Networking/
Session/
Errors/
Config/
Features/
Auth/
UI/
Domain/
Data/
Leagues/
UI/
Domain/
Data/
Draft/
UI/
Domain/
Data/
Roster/
UI/
Domain/
Data/
Matchups/
UI/
Domain/
Data/
Scoring/
UI/
Domain/
Data/
Waivers/
UI/
Domain/
Data/
Resources/
- App/ —
@main, root scene, environment / DI bootstrap, composition root that wires Data implementations into Domain ports. - AppCore/ — shared infrastructure only: HTTP client, base URL / env injection, session façade, error types, config. Not a junk drawer for unfinished features. Cross-feature models that cannot live in one feature Domain belong here (or a dedicated
AppCore/Model/if Kit wants that subfolder). - Each
Features/<Name>/— ownsUI/,Domain/, andData/. Prefer feature-local repositories over a godApiService. Auth stays a gate feature, not a primary tab.
Android (clubhouse-league-android)
Prefer Gradle modules for MVP clarity (stronger boundaries than folders alone). Package root: com.pocketlabs.clubhouseleague.*, mirroring modules.
:app
:core:network
:core:session
:core:model
:feature:auth
:feature:leagues
:feature:draft
:feature:roster
:feature:matchups
:feature:scoring
:feature:waivers
Each :feature:* module uses packages:
com.pocketlabs.clubhouseleague.<feature>/
ui/
domain/
data/
| Module | Role |
|---|---|
:app | Application class, nav host, flavor/env bootstrap, composition root |
:core:network | OkHttp/Retrofit or Ktor client, interceptors, base URL injection |
:core:session | Secure token store, session façade, soft-gate clear-on-401 |
:core:model | Shared cross-feature models (League, Roster, Matchup, …) — not duplicated per feature |
:feature:* | Journey UI + Domain ports + Data implementations; Auth is gate-only |
Feature modules depend on :core:* as needed. Feature UI depends on that feature’s Domain; Data implements Domain. No feature module depends on another feature’s ui package.
Day-1 lock notes (Kit / Dex)
Captured from Mobile Team review. Scaffolding PRs must honor these.
iOS (Kit)
- Composition root stays in
App/—AppCoremust not importFeatures/*. - Pending-action types live in
AppCore/Sessionand must surviveASWebAuthenticationSession/ process death (durable storage, not in-memory alone). - AuthKit Client ID + URL schemes live in
App/+Resources/(xcconfig/Info.plist); register bothclubhouseleagueandclubhouseleague-staging. Prefer Auth delivery as source of truth for values — do not restate Client IDs here. - Stub every
Features/<Name>/{UI,Domain,Data}in the first scaffolding PR. - Keep a single app target for MVP; defer SPM splits (thin
AppEnvironment/ initializer DI).
Not blocking on Universal Links or a shared UI kit.
Android (Dex)
- No
:core:network→ concrete:core:sessiondependency cycle — bind aTokenProviderin:app. - Pending-action types live in
:core:model/ session and must survive Custom Tabs process death. - AuthKit schemes + product flavors live in
:app(Client ID / schemes SoT remains Auth delivery). - Stub all modules in the first scaffolding PR.
- Prefer
build-logicconvention plugins so ~10 modules do not fork AGP versions. - HTTP stack locked: Retrofit + OkHttp in
:core:network.
Not blocking on Hilt or App Links.
Cross-cutting
| Topic | Recommendation |
|---|---|
| API base URL / env | Inject per build flavor. Concrete staging/prod URLs are OD-6 (Blake) — see Deploy & environments. Clients must not hardcode secrets. |
| Error model | Shared shape in spirit: transport vs 401 vs domain/validation. Map 401 → clear session + soft-gate; do not silently retry gated writes. |
| Logging | Structured logs without PII, tokens, or AuthKit codes. See Secrets hygiene. |
| Testing | Unit-test domain use cases and repository fakes first. UI smoke / snapshot later — not a merge blocker for this architecture lock. |
Open decisions (architecture ODs)
Left open for Kit / Dex / Reed challenge. Not product locks.
| ID | Topic | Notes | Owner |
|---|---|---|---|
| AOD-1 | Exact DI approach | iOS: initializer injection vs lightweight container; Android: manual / Hilt / kotlin-inject — pick per platform, keep constructors testable | Kit / Dex |
| AOD-2 | Analytics vendor | Stub interface in AppCore only until product picks a vendor | Remy → Morgan |
| AOD-3 | Offline / cache scope | MVP assumes online for gated writes; browse cache depth TBD | Kit / Dex + Remy |
| AOD-4 | Android HTTP stack | Locked: Retrofit+OkHttp in :core:network | Dex + Remy |
| AOD-5 | API error envelope | Align client error types with Reed’s response shape once published | Reed + Remy |
| AOD-6 | Design tokens packaging | Shared tokens later; no shared UI kit in this proposal | Remy / Mira (runbook only) |
Product/ops ODs that architecture consumes but does not own: OD-6 (env/API URLs), OD-9 (Universal Links / assetlinks remaining). See Open decisions.
Explicit non-goals
- No shared UI kit, design-system package, or cross-compile UI in MVP.
- No feature implementation, tickets closed, or app-repo code in this PR — runbook lock only (scaffolding PRs come later against this layout).
- No feature UI wiring and no sign-in background assets or brand dumps into
clubhouse-league-ios/clubhouse-league-androidfrom this workstream. - No passwords, embedded credential WebViews, or inventing AuthKit Client IDs / schemes beyond Auth delivery.
- No Flutter / RN / KMP evaluation reopening — twin-native is locked in Twin apps & disbursement.
How Kit / Dex should use this
- Challenge AODs and stack defaults in the PR thread or Mobile Team.
- When Remy disburses work (including empty-starter scaffolding PRs), implement against Code architecture — the iOS folder tree and Android Gradle modules above — plus the layers and stack defaults, unless an AOD is explicitly flipped.
- Keep product acceptance on Feature / Journey pages; keep eng how-to under Feature → Implementation notes or app-repo READMEs.
- Locking this page locks code layout for scaffolding; it does not authorize feature UI wiring or sign-in assets in the app repos from this PR.